这是一篇发布在dotnet 团队博客上由微软Graph首席软件工程师 Joao Paiva 写的文章,原文地址:https://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。
从 .NET Framework 升级到 .NET Core 3.1,在相同的流量下,我们观察到 CPU 减少了 30%。
从 .NET Core 3.1 到 .NET 5,我们没有观察到有意义的差异。
从 .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 利用率。
当我们比较两个.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 所引入的Spans 和 string.Create 为我们提供了一个操作字符串的工具,避免了堆上不必要的字符串分配成本。
此外,我们依靠可观察性工具来监视在 CPU、内存、文件和网络 I/O 等维度上代码的成本。这些工具帮助我们识别回归和机会,以改善处理延迟、运营成本和可扩展性。
我们通过新的 API 和 C# 特性获得了非常显著的优势:
通过array pooling 减少缓冲区分配。
减少与内存和span相关的类型的缓冲区和字符串分配。
减少使用静态匿名函数从封闭上下文中捕获状态的委托分配。
使用 ValueTask 减少任务分配。
使用 nullable 删除整个代码库中冗余的 null 检查。
使用null-coalescing assignment 或 using declarations编写简洁的代码,仅举两例。
此列表未涵盖许多其他改进,包括算法和数据结构以及重要的体系结构和基础结构改进。最终,.NET Core和语言功能使我们能够提高工作效率,并编写算法和数据结构,以减少时间和空间的复杂性,这对于实现我们的长期目标至关重要。
最后但并非最不重要的一点是,.NET Core使我们的服务准备好在Windows和Linux中运行,并使我们能够通过HTTP/3和gRPC等传输协议快速创新。
迁移指南
本节介绍从 ASP.NET 迁移到 ASP.NET 核心环境所采用的策略,旨在作为高级指导。
步骤 1 — 构建现代化
第一个先决条件是允许您构建 .NET Framework 和 .NET Core 程序集的生成系统(如果情况并非如此)。
对于 Graph 团队来说,对生成系统进行现代化改造不仅使迁移到 .NET Core 成为可能,而且还通过更快的生成和更易于创建和维护的项目,大大提高了我们的工作效率。
第 2 步 — 架构就绪
拥有良好的体系结构来执行迁移非常重要。让我们使用图表作为我们将要经历的三个主要阶段的插图。
在第 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.HttpContext 和 Microsoft.AspNetCore.Http.HttpContext 。此外,我们在下游程序集中实现路由规则,与主机无关,这也简化了迁移。该服务没有 UI 或视图组件。如果您有一个具有 MVC 和模型绑定的视图组件,则解决方案必然会更加复杂。
步骤 3 — .NET Framework 依赖项的清单
创建服务使用的所有依赖项的清单,这些依赖项仅属于 .NET Framework,并标识所有者以在需要时与它们进行交互。
根据相关性和投资回报对每个依赖关系进行分类。使用和维护依赖关系会带来一些包袱和税收,它们是值得的。通常,良好的依赖关系遵循以下原则:
它不携带隐式依赖项,除了 .NET 运行时或扩展。
它解决了一个不容易解决的有意义的问题,或者逻辑非常敏感,不需要重复。
它具有良好的质量,可靠性和性能,特别是在热路径中存在时。
它得到了积极的维护。
如果不满足这些前提中的任何一个,则可能是时候找到替代方案了,要么通过找到另一个执行该工作的依赖项,要么通过实现它。
大多数流行的库已经是以.NET Standard为目标,许多甚至以.NET Core为目标。对于任何专门针对 .NET Framework 的库,通常已经在所有者的雷达中在 .NET Standard 中构建它们。大多数人都非常乐于接受这样的工作。可以与库的所有者联系,了解提供 .NET Core 兼容版本的时间表。
步骤 4 — 从项目库中摆脱 .NET Framework 依赖项
开始逐个迁移依赖项,移动到 .NET Standard 中的等效项。如果解决方案中有许多项目,请按照自下而上的方法开始处理位于依赖项链底部的项目,因为它们通常具有最少数量的依赖项并且更易于迁移。
面向 .NET Framework 的项目可以继续这样做,而迁移工作正在进行中。一旦项目不再引用任何 .NET Framework 依赖项,请将其设置为 .NET Standard。
第 5 步 — 避免被阻止
如果服务具有旧版或规模很大,则可能会发现隐藏了难以摆脱的依赖项。不要放弃。
请考虑以下选项:
自愿帮助所有者将依赖项构建为 .NET Standard,以便自行取消阻止。
将代码分叉,并将其代码放到你的代码库中生成为 .NET Standard,作为临时的解决方案,直到兼容的版本可用。
将依赖项作为单独的控制台应用程序或与 .NET Framework 一起运行的后台服务运行。现在,你的服务可以在 ASP.NET Core 中运行,而控制台应用程序或后台服务可以在 .NET Framework 中运行。
作为最后的手段,请尝试从 .NET Core 项目中引用依赖项,包括 .NET Framework ProjectReference 或 PackageReference .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 字符百分比。
按主机自动解码百分比编码字符。
使用 .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 网关姊妹团队发布的以下博客:
Azure Active Directory 的网关采用 .NET Core 3.1!
Azure Active Directory 的网关采用 .NET 6.0!
总结
每个新版本的 .NET 都带来了巨大的生产力和性能改进,这些改进继续帮助我们实现构建可扩展服务的目标,这些服务具有高可用性、安全性、最小的延迟开销和最佳路由,同时具有尽可能低的运营成本。
请放心,没有银弹。在大多数情况下,迁移需要团队的认真承诺和辛勤工作。但从长远来看,这项工作无疑会带来许多红利。