Microsoft Graph 的 .NET 6 之旅

這是一篇發佈在dotnet 團隊博客上由微軟Graph首席軟件工程師 Joao Paiva 寫的文章,原文地址: //devblogs.microsoft.com/dotnet/microsoft-graph-dotnet-6-journey/

Microsoft Graph 是一個 API 網關,它提供了對 Microsoft 365 生態系統中數據和智能的統一訪問。 該服務需要實現兩大目標:以非常高的規模運行並有效利用 Azure 計算資源。 我們使用 .NET 構建雲原生的應用已經能夠實現這兩個目標。 我將向您詳細介紹我們是如何將 Microsoft Graph 構建到現在這樣海量服務中的過程。

.NET 6 之旅

四年前,該服務採用 .NET Framework 4.6.2 上的 ASP.NET 運行在 IIS 上。現在該服務採用 .NET 6 上ASP.NET Core 運行在 HTTP.sys 上。  從 .NET Core 3.1 到 .NET 5 ,隨着每次升級我們觀察到 CPU 利用率有所提高,尤其是在 .NET Core 3.1 和最近使用 .NET 6。

  1. 從 .NET Framework 升級到 .NET Core 3.1,在相同的流量下,我們觀察到 CPU 減少了 30%。
  2. 從 .NET Core 3.1 到 .NET 5,我們沒有觀察到有意義的差異。
  3. 從 .NET 5 到 .NET 6,對於相同的流量,我們觀察到 CPU 又減少了 10%。

CPU 利用率的大幅降低轉化為更低的延遲、更高吞吐量和計算容量時的有意義的成本節約,有效地幫助我們實現了目標。

該服務覆蓋全球,目前部署在全球 20 個地區。四年前,該服務每天處理 10 億個請求,運營成本極高。如今,它每天處理大約 700 億個請求,增長了 70 倍,每處理 10 億個請求,運營成本就降低了 91%。這反映了過去 4 年的增長和改進步伐,其中從.NET Framework遷移到 .NET Core 發揮了重要作用。

.NET Core 的影響

從 .NET Framework 4.6.2 (IIS + ASP.NET) 到 .NET Core 3.1 (Kestrel + ASP.NET Core;以及後來的 HTTP.sys) 的初始遷移過程中,我們的基準測試顯示吞吐量顯着提高。 下圖比較了堆棧,並繪製了使用 Standard_D3_v2 虛擬機和合成流量的每秒請求數 (RPS) 和 CPU 利用率。

image

當我們比較兩個.NET 運行時堆棧,該圖表說明了 RPS 相對於相同 CPU 利用率的顯着增加。 在 60% CPU 時,老的.NET Framework 4.6.2(橙色)中的 RPS 約為 350,新的.NET Core 3.1(藍色)中的 RPS 約為 850。 .NET Core 在更高的 CPU 閾值下性能明顯更好。

重要的一點是要注意此基準測試使用的是合成流量,並且觀察到的改進不一定直接轉化為具有真實流量的更大規模生產環境。 在生產中,我們觀察到 CPU下降了 30%(對於相同的流量)。

構建系統的現代化‎

‎我們的構建系統的現代化是 遷移到 .NET Core 成為可能的一項重大任務。‎

‎ 我們使用的是內部構建系統的時候,構建系統工具鏈與 .NET Core 不兼容。因此,在我們的案例中,第一步是使構建系統現代化。我們遷移到了一個更新的現代構建系統,主要使用具有‎‎MSBuild‎‎和‎‎dotnet‎‎支持的Visual Studio工具鏈。新的工具鏈支持.NET Framework和.NET Core,並為我們提供了所需的靈活性。‎

‎ 對構建系統進行現代化改造的投資雖然一開始很困難,但它通過更快的構建和項目,更容易創建和維護,大大提高了我們的生產力。

整體情況

每次 .NET 升級都有許多改進,即使 Graph 團隊沒有執行任何顯式工作來提高性能也是如此。每個新的 .NET 版本都改進了底層運行時 API、通用算法和數據結構,從而導致 CPU 周期和 GC 工作的減少。對於像 Microsoft Graph 這樣受計算約束的服務,使用新的運行時和算法來減少時間和空間複雜性至關重要,並且是使服務快速且可縮放的最有效方法之一。在 .NET 團隊的朋友的幫助下,我們能夠提高吞吐量、減少延遲開銷和計算運營成本。謝謝!

遷移的另一個原因是使代碼庫現代化。現代的代碼庫更能吸引了人才(招聘),並使我們的開發人員能夠使用更新的語言功能和API來編寫更好的代碼。像.NET Core中引入的 spans 這樣的構造是無價的。我使用 span 的常見方法之一是字符串操作。字符串操作是老的 .NET 代碼庫中的常見陷阱。由於無休止的連接給GC帶來了壓力,最終反映在更高的CPU成本上,舊模式通常會導致字符串分配的爆炸式增長。開發人員甚至沒有意識到這種分配的實際成本和影響。.NET Core 所引入的Spansstring.Create  為我們提供了一個操作字符串的工具,避免了堆上不必要的字符串分配成本。

此外,我們依靠可觀察性工具來監視在 CPU、內存、文件和網絡 I/O 等維度上代碼的成本。這些工具幫助我們識別回歸和機會,以改善處理延遲、運營成本和可擴展性。

我們通過新的 API 和 C# 特性獲得了非常顯著的優勢:

  1. 通過array pooling 減少緩衝區分配。
  2. 減少與內存和span相關的類型的緩衝區和字符串分配。
  3. 減少使用靜態匿名函數從封閉上下文中捕獲狀態的委託分配。
  4. 使用 ValueTask 減少任務分配。
  5. 使用 nullable 刪除整個代碼庫中冗餘的 null 檢查。
  6. 使用null-coalescing assignmentusing declarations編寫簡潔的代碼,僅舉兩例。

此列表未涵蓋許多其他改進,包括算法和數據結構以及重要的體系結構和基礎結構改進。最終,.NET Core和語言功能使我們能夠提高工作效率,並編寫算法和數據結構,以減少時間和空間的複雜性,這對於實現我們的長期目標至關重要。

最後但並非最不重要的一點是,.NET Core使我們的服務準備好在Windows和Linux中運行,並使我們能夠通過HTTP/3和gRPC等傳輸協議快速創新。‎

遷移指南

本節介紹從 ASP.NET 遷移到 ASP.NET 核心環境所採用的策略,旨在作為高級指導。

步驟 1 — 構建現代化

第一個先決條件是允許您構建 .NET Framework 和 .NET Core 程序集的生成系統(如果情況並非如此)。

對於 Graph 團隊來說,對生成系統進行現代化改造不僅使遷移到 .NET Core 成為可能,而且還通過更快的生成和更易於創建和維護的項目,大大提高了我們的工作效率。

第 2 步 — 架構就緒

擁有良好的體系結構來執行遷移非常重要。讓我們使用圖表作為我們將要經歷的三個主要階段的插圖。

image

  • 在第 1 階段,我們有 ASP.NET Web 服務器程序集和面向 .NET Framework(黃色)的所有庫。
  • 在第 2 階段,我們有兩個 Web 服務器程序集,每個程序集都面向各自的 .NET 運行時,而庫現在面向 .NET Standard(藍色)。這樣可以進行 A/B 測試。
  • 在第 3 階段,我們有一個 Web 服務器程序集和所有面向 .NET Core(綠色)的庫。

如果你的解決方案尚未在多個程序集中分解(階段 1),則現在是執行此操作的好機會。ASP.NET 程序集應該是 Web 服務器的非常薄的存根,從主機中抽象出應用程序。此 ASP.NET 程序集應特定於主機,並引用實現各個組件(如控制器、模型、數據庫訪問等)的下游庫。重要的是要有一個具有關注點分離的體系結構模式,因為這有助於簡化依賴關係鏈和遷移工作。

在我們的服務中,這是通過單個 HTTP 應用程序處理程序來完成的,該處理程序是特定於主機的傳入請求。該處理程序將傳入的轉換為與主機無關的等效對象,該對象將傳遞到下遊程序集,這些程序集使用該對象讀取傳入的請求並寫入響應。我們使用的接口分別抽象了每個主機環境所使用的傳入 System.Web.HttpContextMicrosoft.AspNetCore.Http.HttpContext 。此外,我們在下遊程序集中實現路由規則,與主機無關,這也簡化了遷移。該服務沒有 UI 或視圖組件。如果您有一個具有 MVC 和模型綁定的視圖組件,則解決方案必然會更加複雜。

步驟 3 — .NET Framework 依賴項的清單

創建服務使用的所有依賴項的清單,這些依賴項僅屬於 .NET Framework,並標識所有者以在需要時與它們進行交互。

根據相關性和投資回報對每個依賴關係進行分類。使用和維護依賴關係會帶來一些包袱和稅收,它們是值得的。通常,良好的依賴關係遵循以下原則:

  1. 它不攜帶隱式依賴項,除了 .NET 運行時或擴展。
  2. 它解決了一個不容易解決的有意義的問題,或者邏輯非常敏感,不需要重複。
  3. 它具有良好的質量,可靠性和性能,特別是在熱路徑中存在時。
  4. 它得到了積極的維護。

如果不滿足這些前提中的任何一個,則可能是時候找到替代方案了,要麼通過找到另一個執行該工作的依賴項,要麼通過實現它。

大多數流行的庫已經是以.NET Standard為目標,許多甚至以.NET Core為目標。對於任何專門針對 .NET Framework 的庫,通常已經在所有者的雷達中在 .NET Standard 中構建它們。大多數人都非常樂於接受這樣的工作。 可以與庫的所有者聯繫,了解提供 .NET Core 兼容版本的時間表。

步驟 4 — 從項目庫中擺脫 .NET Framework 依賴項

開始逐個遷移依賴項,移動到 .NET Standard 中的等效項。如果解決方案中有許多項目,請按照自下而上的方法開始處理位於依賴項鏈底部的項目,因為它們通常具有最少數量的依賴項並且更易於遷移。

面向 .NET Framework 的項目可以繼續這樣做,而遷移工作正在進行中。一旦項目不再引用任何 .NET Framework 依賴項,請將其設置為 .NET Standard。

第 5 步 — 避免被阻止

如果服務具有舊版或規模很大,則可能會發現隱藏了難以擺脫的依賴項。不要放棄。

請考慮以下選項:

  1. 自願幫助所有者將依賴項構建為 .NET Standard,以便自行取消阻止。
  2. 將代碼分叉,並將其代碼放到你的代碼庫中生成為 .NET Standard,作為臨時的解決方案,直到兼容的版本可用。
  3. 將依賴項作為單獨的控制台應用程序或與 .NET Framework 一起運行的後台服務運行。現在,你的服務可以在 ASP.NET Core 中運行,而控制台應用程序或後台服務可以在 .NET Framework 中運行。
  4. 作為最後的手段,請嘗試從 .NET Core 項目中引用依賴項,包括 .NET Framework ProjectReferencePackageReference .NET Core 運行時使用兼容性填充程序,允許您加載和使用某些 .NET Framework 程序集。但是,不建議將此作為永久性措施。必須(在運行時)對此方法進行詳盡的測試,因為即使生成成功,也無法保證程序集兼容(在所有代碼路徑中)。NoWarn="NU1702"

在 Microsoft Graph 遷移的案例中,我們在不同的時間和不同的依賴項中使用了所有這些選項。目前,我們仍然將一個控制台應用程序作為 .NET Framework 運行,並使用兼容性填充程序在服務中加載一個 .NET Framework 程序集。

步驟 6 — 為 ASP.NET Core 創建新的 Web 服務器項目

使用等效設置,為 ASP.NET Core 創建一個新項目,與當前 ASP.NET 框架項目並行。新 ASP.NET Core 項目默認使用 Kestrel。它非常好,是大多數.NET團隊投資的地方。這是他們的跨平台Web服務器。但是,您可以考慮其他選擇,例如HTTP.sys,IIS甚至NGINX。

請確保在 .NET Core 中啟用較新的性能計數器。花點時間來啟用它們,特別是與CPU,GC,內存和線程池相關的。還要為所選的 Web 服務器啟用性能計數器(例如,請求隊列)。當您開始實施時,這些對於檢測任何回歸或異常非常重要。

此時,您應該已完成第 2 階段(在我上面圖片中),並準備好執行 A/B 測試並開始實施。

步驟 7 — A/B 測試和實施計劃

創建一個實施計劃,該計劃允許在通過所有預生產關口後,在某些生產容量中進行 A/B 測試(例如,將新運行時部署到一個規模集)。使用真實流量進行大規模測試是最終的大門和關鍵時刻。

您可以使用以下啟發式方法測量應用程序之前和之後的效率,測量 A/B 位之間的差異:

Efficiency = (Requests per second) / (CPU utilization)

在第一次實施期間,盡量減少在有效負載中引入的更改,以減少可能導致意外回歸的變量數。如果我們在有效負載中引入太多變量,我們就會增加引入其他可能與新運行時無關的錯誤的可能性,但仍會浪費工程師的時間來確定和根本原因。

一旦初始部署在小規模內成功並經過審查,請按照現有的安全部署實踐逐步實施,計劃使用逐步推出來啟用新位。重要的是要遵循逐步實施,這樣您就可以及時檢測和緩解可能隨着數量和規模的增加而出現的問題。

步驟 8 — 在所有項目中以 .NET Core 為目標

一旦服務在 ASP.NET Core 中運行,大規模部署並經過審查,就可以刪除 .NET Framework 中仍然存在的最後一個片段了。刪除用於 ASP.NET 的 Web 服務器項目,並將所有項目庫顯式移動到 .NET Core 而不是 .NET Standard,以便您可以開始使用較新的 API 和語言功能,使開發人員能夠編寫更好的代碼。有了這個,你已經成功地完成了第3階段

升級技巧

應用了一些主要的學習和升級技巧。

URI 編碼中的怪癖

該服務的一個核心功能是分析傳入的 URI。多年來,我們最終在整個代碼庫中都有不同的點,對傳入請求的編碼方式進行了嚴格的假設。當我們從 ASP.NET 轉移到 ASP.NET Core時,許多這些假設都被違反了,導致許多問題和邊緣情況。經過長時間的修復和分析,我們整合了以下規則,用於將 ASP.NET Core Path和Query轉換為代碼不同部分所需的老的 ASP.NET 格式。

  • 按主機列出的被拒絕的編碼 ASCII 字符百分比。

    image

  • 按主機自動解碼百分比編碼字符。

    image

使用 .NET 6 啟用動態 PGO

在.NET 6中,我們啟用了動態PGO,這是.NET 6.0最令人興奮的功能之一。PGO 可以通過最大限度地提高穩態性能而使 .NET 6.0 應用程序受益。

動態 PGO 是 .NET 6.0 中的一項選擇加入功能。需要設置 3 個環境變量才能啟用動態 PGO:

  • set DOTNET_TieredPGO=1.此設置利用方法的初始 Tier0 編譯來觀察方法行為。在 Tier1 重新設置方法時,將從 Tier0 執行收集的信息用於優化 Tier1 代碼。
  • set DOTNET_TC_QuickJitForLoops=1.此設置為包含循環的方法啟用分層。
  • set DOTNET_ReadyToRun=0. 默認情況下,.NET 附帶的核心庫都啟用了 ReadyToRun。ReadyToRun允許更快的啟動,因為JIT編譯較少,但這也意味着ReadyToRun映像中的代碼不會經過支持動態PGO的Tier0分析過程。通過禁用 ReadyToRun,.NET 庫還可以參與動態 PGO 過程。

這些設置使 Azure AD 網關的應用程序效率提高了 13%。

其他參考資料

有關更多了解,請參閱 Azure AD 網關姊妹團隊發佈的以下博客:

總結

每個新版本的 .NET 都帶來了巨大的生產力和性能改進,這些改進繼續幫助我們實現構建可擴展服務的目標,這些服務具有高可用性、安全性、最小的延遲開銷和最佳路由,同時具有儘可能低的運營成本。

請放心,沒有銀彈。在大多數情況下,遷移需要團隊的認真承諾和辛勤工作。但從長遠來看,這項工作無疑會帶來許多紅利。