天天看點

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

作者:DotNET技術圈

本次遷移涉及的是公司内部一個業務子系統,該系統是一個多樣化的應用,支撐着公司的多個業務方向。目前,該系統由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微服務遷移至.NET6.0的故事

生産力和性能

大家都知道,在.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/#section=data-r21&test=composite

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

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

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

在評測GRPC性能的grpc_bench中,C#和.NET以

141906req/s

的速度和

5.76ms

的平均延時取的了第一的成績。https://github.com/LesnyRumcajs/grpc_bench/discussions/310

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

可以看到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

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

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

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代碼            
.NET微服務遷移至.NET6.0的故事

釋出計劃

我們的釋出計劃基本也是進行灰階釋出,可以在7層網關對新舊應用進行流量權重配置設定,更簡單的方式就是直接替換叢集内的某些容器鏡像達到流量切換的效果,我們選擇更簡單的方式來處理。

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

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

遷移結果

關于性能的提升

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

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

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

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

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

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

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

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

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

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

更完善的觀測名額

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

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

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

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

性能提升來自哪裡?

更新.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,歡迎大家加入。

繼續閱讀