.NET微服務系統遷移至.NET6.0的故事

2023-02-23 12:01:19

本次遷移涉及的是公司內部一個業務子系統,該系統是一個多樣化的應用,支撐著公司的多個業務方向。目前,該系統由40多個基於.NET的微服務應用構成,使用數千個CPU核心和數TB記憶體,在數百個Linux容器中執行。每天,該系統需要處理數十億次請求。

該系統其中大部分服務是在2018-2019年左右由老舊.NET Faremwork、Java等系統重構而來,當時使用的是.NET Core 2.1,這幾年業務迭代陸續新建了一些服務,所以該系統大部分服務是.NET Core 2.1,也有小一部分採用的是.NET Core 3.1和.NET5.0。

如今5年過去了,.NET的版本已經來到了7.0,相較於之前的版本它加入了非常多先進的特性、提升了效能、加入可觀測性支援、更加適應容器化環境的部署;而現在的.NET Core 2.1讓我們有很多效能提升和新的特性都無法享受到。

為了享受更新的特性和效能提升,我們團隊在最近的一段時間裡面完成了.NET Core 2.1和.NET 5.0向.NET 6.0的遷移,其中發生踩了一些坑,最後也獲得了不錯的結果,特意在這裡和大家分享這整個過程。

為什麼不是向.NET 7.0遷移?首先是因為.NET7.0在我們內部中的元件還沒得到很好的支援,另外.NET6.0是LTS版本而.NET7.0不是;而且從.NET6.0向.NET7.0遷移非常簡單,後續可以直接升級。所以綜合考慮,我們決定先升級到.NET6.0版本。

為什麼是.NET

那麼有很多朋友會有疑問,現在有很多面向雲原生的程式語言和框架,我們為什麼選擇了使用.NET?我想從幾個方面解答這個問題。

歷史原因

.NET見證了網際網路的起步階段,很多大家能想到的網際網路應用一開始都是基於.NET技術構建,特別是在我們這個行業更是如此;下圖是統計十多個微服務專案程式碼,可以發現有近700萬行.NET程式碼(包括C#、ASP.NET、Razor等等),所以對於我們來說,繼續在.NET上投資是一個很好的選擇,沒有什麼理由更換其它的技術。

生產力和效能

大家都知道,在.NET平臺上可以執行很多語言,比如C#、F#、JavaScript、PHP、Python等等,其中使用量最大的就是C#,而C#它有很多先進的語法特性,可以極大的提升我們的生廠力和程式的能。比如:

  • 泛型:泛型是一個普遍存在的特性,它允許將類專門化為一種或多種型別。例如,List<T>是一個開放的泛型類,而像List<string>List<int>這樣的範例化則避免了對單獨的ListOfString和ListOfInt類的需求,或者像ArrayList那樣依賴於物件和轉換。泛型還能夠在不同的型別之間建立有用的系統(並減少對大量程式碼的需求),比如泛型數學。另外,C#的泛型不是泛型擦除,而是執行時生成泛型本機程式碼,對於值型別可以避免裝箱拆箱,極大降低GC壓力。
  • 委託和lambda:委託和 Lambda 表示式允許將方法作為資料進行傳遞,這使得將外部程式碼整合到由另一個系統擁有的操作流程中變得容易。它們是一種「粘合程式碼」,它們的簽名通常是泛型的,以允許廣泛的實用性。
  • 擴充套件方法和Linq:擴充套件方法允許向現有類新增新方法,而不需要修改類的原始碼,極大增強了擴充套件性,而最著名的例子就是LINQ,它一種功能強大查詢語言,允許使用類似 SQL 的語法查詢各種資料來源。它包括標準查詢運運算元,如 Where、Select、OrderBy 和 GroupBy 等,它還支援查詢延遲執行、型別推斷和強型別查詢等特性,可以非常方便的在程式碼中實現資料處理。
  • 自定義值型別和棧上分配:值型別和棧上分配的記憶體相對於.NET的受GC管理的型別提供了更直接、低階的資料和本機平臺互動控制。.NET中的大多數原始型別,如整數型別,都是值型別,使用者可以定義具有類似語意的自定義值型別。完全支援值型別。NET 的泛型系統,這意味著像 List<T>這樣的泛型型別可以提供扁平的、無開銷(無需裝箱拆箱)的值型別集合。另外.NET泛型在替換值型別時提供專門的編譯程式碼,這意味著這些泛型程式碼路徑可以避免昂貴的GC開銷。
  • 無棧協程與非同步:非同步程式設計是一種基於任務(Task)和非同步操作(Async Operation)的並行模型,可以使用 async/await 關鍵字來實現,我們叫它無棧協程。非同步程式設計中的程式碼可以在等待非同步操作完成時繼續執行其他任務,從而充分利用 CPU 和 IO 資源,提高程式的並行性和響應性。非同步程式設計通常用於處理 IO 密集型任務,比如網路通訊、檔案操作等。
  • 直接操作記憶體:C#原生支援指標,可以很方便的直接操作記憶體,在後續的版本中,更是提供了安全的記憶體操作庫,例如Span、Memory、Unsafe等,它們可以繞過C#記憶體管理機制,直接操作記憶體。這種方式在一些場景下可以帶來媲美C/C++的效能。

另外在一些程式語言和框架效能排行上,C#和.NET的效能也是名列前茅的。在TechEmpower釋出的WEB框架效能天梯中,基於C#和.NET構建的ASP.NET Core框架排名第七,在功能完備的WEB框架中僅次於Rust和C++框架。
https://www.techempower.com/benchmarks/

在科學計算的Benchmaks Game中,C# .NET名列第5,僅次於C、C++、Rust等一些編譯型語言;執行速度是JIT語言中最快的,記憶體佔用也是JIT語言中最低的。
https://benchmarksgame-team.pages.debian.net/benchmarksgame/index.html

在評測GRPC效能的grpc_bench中,C#和.NET以141906req/s的速度和5.76ms的平均延時取的了第一的成績。
https://github.com/LesnyRumcajs/grpc_bench/discussions/310

可以看到C#語言和.NET框架在極致的效能和生產力之間取得了很好的平衡,我們恰恰就是需要這樣的框架。

法律風險

C# 和 .NET現階段都是用MIT協定開源,允許使用者在滿足一些簡單條件的前提下,自由地使用、複製、修改和分發軟體,因為MIT協定非常寬鬆,使用者可以自由地使用和分發軟體,不必擔心任何版權或專利問題。

遷移過程

此次遷移要最大的保證業務相容性,就是不修改任何一行業務程式碼,只進行框架遷移。所以實際上改動非常小,幾乎沒有佔用什麼測試人力,因為只需要迴歸一些主要業務流程。

程式碼遷移

在遷移過程中踩了一些坑,其實這些不應該說是遷移中踩的坑,因為在.NET社群的檔案中,有非常完整的遷移流程,跟著遷移流程來不會有什麼問題,只是有一些要注意的地方。下方是.NET社群提供的每個版本的遷移檔案:

https://learn.microsoft.com/zh-cn/aspnet/core/migration/50-to-60?view=aspnetcore-7.0&tabs=visual-studio

有一些需要注意的地方,主要是以下幾點:

System.Text.Json序列化
我們主要是WebAPI站點,從.NET Core 2.1升級過程中首先遇到的第一個問題就是序列化的支援,因為以前的版本都是使用的Newtonsoft.Json,在.NET Core 3.1以後預設使用System.Text.Json;雖然System.Text.Json更加規範和效能更強,但是不會相容一些非規範的JSON,為了避免介面契約的變化,我們使用Newtonsoft.Json替換了System.Text.Json。

// 根據不同的服務型別,選擇不同的設定
services.AddMvc().AddNewtonsoftJson();
services.AddControllers().AddNewtonsoftJson();
services.AddControllersWithViews().AddNewtonsoftJson();
services.AddRazorPages().AddNewtonsoftJson();

Endpoint處理
.NET新版本使用Endpoint進行路由關係,如果之前設定了app.UseMvc(),而且進行了路由設定,如果不想遷移的話那麼需要關閉Endpoint的路由支援來相容。

services.AddMvc(options=>{
    options.EnableEndpointRouting = false;
});

非同步Action處理
如果以前是.NET2.1版本,Controller中有Async結尾的Action,那麼在新版本中Async結尾會預設去除,為了保證應用介面契約相容性,我們關閉這個特性的支援。

services.AddMvc(options=>{
    options.SuppressAsyncSuffixInActionNames = false;
});

重複讀流
如果以前是.NET2.1版本,在某些場景中,需要多次讀取請求正文,則需要在app.UseMvc()或者app.UseEndpoints()前進行request.EnableRewind();,在新版本需要改為Request.EnableBuffering();

app.Use(async (context, next) =>  
{  
    context.Request.EnableBuffering();  
    await next(context);  
});

並且在使用完成以後需要重置 request.Body.Position = 0;,不過我們並不建議這樣做,高效能的做法是使用PipeReader來讀流。

request.Body.Position = 0;  
using (var reader = new StreamReader(request.Body, Encoding.UTF8, true, 1024, true))  
{  
    ......
}  
request.Body.Position = 0;

同步讀流
如果以前是.NET2.1版本,預設同步讀request.body流 ,在新版本中為了效能預設就是非同步讀,如果不想修改為非同步讀流(為了效能不建議同步讀流),那麼需要允許同步讀流。

services.Configure<KestrelServerOptions>(options =>  
{  
    options.AllowSynchronousIO = true;  
});

啟用動態PGO
.NET5.0以後的一個新的特性,就是Dynamic Profile-guided Optimization(動態設定引導優化),它會在執行時收集程式碼的執行情況,通過分層編譯自動對程式碼進行優化。在其它博主的評測中,某些場景中有高達32%的提升。

# 設定環境變數
export DOTNET_ReadyToRun=0 # 禁用 AOT 
export DOTNET_TieredPGO=1 # 啟用分層 PGO 
export DOTNET_TC_QuickJitForLoops=1 # 為迴圈使用 tier0程式碼

釋出計劃

我們的釋出計劃基本也是進行灰度釋出,可以在7層閘道器對新舊應用進行流量權重分配,更簡單的方式就是直接替換叢集內的某些容器映象達到流量切換的效果,我們選擇更簡單的方式來處理。

在觀察一段時間沒有問題以後,陸續覆蓋20%、50%、100%的應用,完成切流。

遷移結果

關於效能的提升

遷移後我們驚喜的發現整體的效能都有較大的提升,在某個計算密集型的服務中,CPU佔用率降低30%,而且沒有了CPU毛刺,佔用率曲線更加穩定。

另外記憶體也有一定的下降,雖然這個服務佔用的記憶體很少,不過也是肉眼可見的進步。

在其它服務中,也觀測到了類似的改變,幅度變得更大。

在IO密集型的應用中,我們也驚喜的觀測到了CPU使用率的下降,而且毛刺變少了很多。

我們知道在.NET的新版本中,著重優化了P95耗時,檢視了一些介面的平均耗時,發現相較原來平均耗時降低了50%,非常明顯。

更完善的觀測指標

公司架構團隊基於Opentelemetry完善了.NET上的觀測指標,現在我們可以無侵入無埋點的對應用進行監控,還有一些更底層的.NET執行指標也可以監控。


比起以前的APM,現在也有更詳細的鏈路資料展示。

效能提升來自哪裡?

升級.NET6.0以後,帶來了很大的效能提升,在降低CPU和記憶體佔用的情況下,還降低了P95延時,這一切的背後是什麼?

在每年11月.NET即將釋出正式版之前,.NET社群都會總結一個長達數十頁的檔案,從JIT、GC、執行緒各個方面記錄從上一個版本到這一個版本有哪些效能的提升,可以看到.NET社群為效能提升做的努力。

筆者帶大家從.NET Core 2.0開始,看看每個版本中有哪些令人印象深刻的效能改進。

.NET Freamwork 到 .NET Core 2.0

.NET Freamwork 到 .NET Core 效能提升
https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core/

這是一個跨時代的版本,標誌了.NET從此走向開源、跨平臺,當然在整個跨平臺構建過程中,也有很多重大效能進步,下面列出了比較重大的部分:

  • 集合型別的改進:集合是任何應用程式的基石,.NET 庫中提供了大量集合。並非每個集合上的每個操作都能做得更快,但許多操作都優化的更快了。其中一些改進是因為消除了開銷,例如簡化操作以實現更好的內聯、減少指令數等。比如:Queue類吞吐量提升了6倍、ConcurrentBat吞吐量提高了~30%,而且極大的降低了GC次數。
  • LINQ:LINQ 中的許多運運算元已針對 .NET Core 進行了完全重寫,以便減少分配的數量和大小、降低演演算法複雜性,並通常消除不必要的工作。比如:Select()吞吐量提升了4倍,ToArry()效能提升了6倍。
  • 文書處理:.NET 應用程式中另一種非常常見的計算形式是文書處理,在堆疊的各個級別上進行了大量改進。比如:正規表示式吞吐量提高了70%,記憶體分配減少了231%;對於列舉類的ToString()吞吐量提高了33%,記憶體分配減少了25倍。
  • 網路:網路現在是一大重點領域,未來可能會更加如此。正在投入大量精力來優化和調整網路堆疊的較低階別,以便可以有效地構建更高階別的元件。比如:Socket連結的寫入和接收都減少50%以上的記憶體開銷。
  • 並行:執行緒處理和並行性相關的基礎設定也有許多改進,比如:ThreadPool中優化了佇列演演算法,提升了30%的吞吐量,減少了25%的記憶體分配。

.NET Core 2.1

.NET Core 2.1 效能提升: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-2-1/

.NET Core 2.1 雖然和 .NET Core 2.0只有一個小版本的區別,但是實際上是經過一年多的開發和優化,其中比較重大的變更有:

  • JIT(即時編譯器): 在改進 .NET Core 2.1 中的實時 (JIT) 編譯器方面進行了大量工作,其中進行了許多優化,以增強各種庫和應用程式。其中許多改進都是根據BCL本身的需求尋求的,使這些改進既有針對性又有廣泛的影響。比如:EqualityComparer<T>提升了2.5倍效能、Enum.HasFlag()提升了50倍效能。
  • 執行緒:這些改進有多種形式,無論是在減少低階操作的開銷方面,還是在減少常用執行緒原語中的鎖爭用方面,或是在減少分配方面,或是在總體上改進非同步方法背後的基礎設施方面。比如:存取執行緒靜態區提升20%效能、Timer計時器提升了50%的吞吐量、非同步存取熱路徑減少了30%開銷。
  • String:著重優化了String的效能,使用了向量化、Span<T>等方案,比如:Equals方法吞吐量提升了30%、IndexOf方法吞吐量提升了3倍、ToLower/ToUpper提升了1倍。

.NET Core 3.0

.NET Core 3.0效能提升
https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-core-3-0/

.NET Core 3.0 提供了大量的功能,從Windows表單和WPF,到單檔案可執行檔案,到非同步列舉,到平臺內在因素,到HTTP/2,到快速JSON讀寫,到組合可解除安裝性,到增強的加密技術,等等...有大量的新功能值得興奮。然而,對我來說,效能是讓我早上上班時感到興奮的主要功能,而在.NET Core 3.0中,有大量的效能優化點。其中重大改進有:

  • Span和它的朋友們:.NET Core 2.1中引入的一個更顯著的特性是Span<T>,以及它的朋友ReadOnlySpan<T>Memory<T>ReadOnlyMemory<T>。這些新型別的引入帶來了數百種與之互動的新方法,有些是在新型別上,有些是在現有型別上的過載功能,還有及時編譯器(JIT)中的優化,使其工作非常高效。
  • JIT(即時編譯器):NET Core 3.0最有影響力的變化之一是分層編譯,要做的分析越多,要應用的優化越多,需要的時間越長。因此,一開始使用R2R帶實現更快的啟動,但隨後發現經常使用的方法可以通過分層編譯重新編譯,編譯更高效能的程式碼。

.NET 5

.NET5效能提升
https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-5/

.NET 5已經有了大量的效能改進,文中重點介紹了~250個合併請求,這些請求為整個.NET 5的效能改進做出了巨大的貢獻。其中重大改進有:

  • GC:對於任何對 .NET 和效能感興趣的人,垃圾回收通常是頭等大事。此版本對於改進GC做了很多努力,比如:並行GC中使用偷竊演演算法配平每個執行緒任務、減少GC掃描靜態資料鎖爭用、使用向量化優化GC排序演演算法等等。
  • JIT(即時編譯器):.NET 5 對於即時 (JIT) 編譯器來說也是一個令人興奮的版本,其中許多改進都進入了釋出。與任何編譯器一樣,對 JIT 所做的改進可能會產生廣泛的影響。通常,單個更改對單個程式碼段的影響很小,但這些更改隨後會因它們應用的位置數量而放大。比如:JIT和GC配合向量化初始記憶體、自動優化邊界檢查、自動優化協變檢查、自動優化重複異常丟擲等等。
  • 向量化:在 .NET Core 3.0 中,JIT 新增並識別了一千多種新的硬體內部方法,使 C# 程式碼能夠直接面向 SSE4 和 AVX2 等指令集程式設計,而在.NET5.0中,增加了數千個用於ARM架構的向量化方法,使向量化能在ARM架構晶片上工作良好。

.NET 6

.NET 6 效能提升: https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/

這無疑是.NET社群通力共同作業的一年,.NET6.0總共有超過6500個合併請求,上文整理了~400個關於效能提升的請求,當時.NET社群喊出的口號就是這是最快的.NET版本。其中重大改進有:

  • JIT:程式碼生成是構建其他所有內容的基礎。因此,對程式碼生成的改進具有倍增效應,能夠提高平臺上執行的所有程式碼的效能。.NET 6 在 JIT(即時編譯器)中看到了令人難以置信的大量效能改進。特別是Dynamic PGO(設定引導優化),還有其它優化如:更強大的去虛擬化支援、更強大的方法內聯支援、值型別暫存器分配優化等等。
  • GC:在 GC(垃圾回收器)上的 .NET 6 中發生了大量工作,其中絕大多數工作都是以將 GC 實現將Segment分配切換為Region分配,達到更快的升代和整理速度。另外還有:優化前臺GC的表現、進一步均勻化所有GC堆的任務、增加基於時間衰減演演算法減少GC。
  • 執行緒池:首先,自 .NET 6 起,runtime 中預設的執行緒池實現從 C++ 程式碼改為了 C#,另外.NET6的執行緒池引入了一種新的啟發式演演算法(hill-climbing)爬山演演算法注入執行緒,可有效的降低當任務過多時執行緒池飢餓的情況。
  • 檔案IO:.NET 6 中的有大量工作修復 .NET 中最古老的型別之一的效能:每個應用和服務都讀取和寫入檔案。不幸的是,多年來也一直受到許多與效能相關的問題的困擾,其中大部分是其在Windows上的非同步I/O實現的一部分。在.NET6中,完全重寫了這一部分,在Windows和Unix上都得到了巨大的效能改進。

.NET 7

.NET 7 效能提升: https://devblogs.microsoft.com/dotnet/performance_improvements_in_net_7/

.NET 7毫無疑問的說,它是迄今為止最快的.NET版本,它效能提升是非常巨大的,以至於筆者開啟上面效能優化的說明網頁,瀏覽器足足卡頓了幾十秒。.NET 7相較於.NET 6有多達7000多個提交,其中有1000多個是和效能息息相關的,上文只挑選了500個提交。其中重大改進有:

  • JIT:在.NET7中,JIT迎來了非常大的改進,其中最大的改進就是分層編譯支援了棧上替換(OSR),支援了ARM64晶片架構,另外Dynamic PGO迎來了更多的改進,優化面更加廣泛,比如:消除邊界檢查、迴圈提升和複製、常數替換、向量化、自動內聯等等。
  • NativeAOT:在.NET7中,NativeAOT正式釋出,意味著.NET程式碼可以直接編譯為機器碼,無需執行時,它可以讓系統體積更小、啟動速度更快、記憶體佔用更少。
  • 反射:同樣優化了反射的效能,反射可以讓我們動態的存取型別、方法還可以動態生成程式碼,但是它一直都是一個效能陷阱,在.NET7中著重的優化了反射的效能,在某些場景可以達到80%的效能提升。
  • 執行緒*:執行緒是影響每個應用程式的橫切關注點之一,因此執行緒空間的更改可能產生廣泛的影響。這個版本看到了 ThreadPool 本身的兩個非常重大的變化; 將「IO執行緒池池」切換到使用一個完全C#程式碼的實現(而之前的 IO 池仍然在C++程式碼中,即使工作者池在以前的版本中已經完全移動到託管) ,另外將Timer實現從基於C++的實現切換到完全C#程式碼中的實現。兩者均提升了將近30%的效能。

總結

總的來說,本次.NET6.0的遷移還是非常成功的,簡單的通過版本升級就能獲得效能提升,而且還可以享受新版.NET和C#帶給我們新的特性,如果有什麼問題請私信或者評論,歡迎交流!

其它文章

遷移至.NET5.0後CPU佔用降低: https://twitter.com/stebets/status/1442417534444064769

StackOverflow遷移至.NET5.0: https://twitter.com/juanrodriguezce/status/1428070925698805771

StackOverflow遷移至.NET6.0: https://wouterdekort.com/2022/05/25/the-stackoverflow-journey-to-dotnet6/

必應廣告活動平臺遷移至.NET6.0: https://devblogs.microsoft.com/dotnet/bing-ads-campaign-platform-journey-to-dotnet-6/

Microsoft Commerce的.NET6.0遷移之旅: https://devblogs.microsoft.com/dotnet/microsoft-commerce-dotnet-6-migration-journey/

Microsoft Teams服務到.NET6.0的旅程: https://devblogs.microsoft.com/dotnet/microsoft-teams-assignments-service-dotnet-6-journey/

OneService 到 .NET 6.0的旅程 :https://devblogs.microsoft.com/dotnet/one-service-journey-to-dotnet-6/

Exchange 線上版遷移至 .NET Core: https://devblogs.microsoft.com/dotnet/exchange-online-journey-to-net-core/

Azure Cosmos DB 到 .NET 6.0的旅程: https://devblogs.microsoft.com/dotnet/the-azure-cosmos-db-journey-to-net-6/

.NET效能優化交流群

相信大家在開發中經常會遇到一些效能問題,苦於沒有有效的工具去發現效能瓶頸,或者是發現瓶頸以後不知道該如何優化。之前一直有讀者朋友詢問有沒有技術交流群,但是由於各種原因一直都沒建立,現在很高興的在這裡宣佈,我建立了一個專門交流.NET效能優化經驗的群組,主題包括但不限於:

  • 如何找到.NET效能瓶頸,如使用APM、dotnet tools等工具
  • .NET框架底層原理的實現,如垃圾回收器、JIT等等
  • 如何編寫高效能的.NET程式碼,哪些地方存在效能陷阱

希望能有更多志同道合朋友加入,分享一些工作中遇到的.NET效能問題和寶貴的效能分析優化經驗。目前一群已滿,現在開放二群。
如果提示已經達到200人,可以加我微信,我拉你進群: ls1075
另外也建立了QQ群,群號: 687779078,歡迎大家加入。