Microsoft Graph是一个API网关,提供对Microsoft 365生态系统中数据和智能的统一访问。该服务需要以非常高的规模运行,并有效利用Azure计算资源。我们已经能够实现这两个目标,使用.NET作为我们选择的云堆栈。我将告诉你更多关于我们将Microsoft Graph建设成今天这样的服务的历程。
通往.NET 6的旅程
四年前,该服务运行在IIS和ASP.NET的.NET框架4.6.2上。今天,该服务在HTTP.sys和ASP.NET Core上运行,在.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 Core的迁移也起到了很大作用。
.NET核心的影响
在最初从.NET框架4.6.2(IIS + ASP.NET)迁移到.NET Core 3.1(Kestrel + ASP.NET Core;后来是HTTP.sys)的过程中,我们的基准测试显示吞吐量有了明显的改善。下图比较了两个堆栈,并绘制了使用Standard_D3_v2虚拟机的每秒请求数(RPS)和CPU利用率,以及合成流量。

该图显示,当我们比较两个堆栈时,相对于相同的CPU利用率,RPS有了很大的提高。在60%的CPU下,我们在旧堆栈(橙色)中的RPS约为350,在新堆栈(蓝色)中的RPS为850。新的堆栈在更高的CPU阈值下表现明显更好。
值得注意的是,这个基准使用合成流量,观察到的改进不一定直接转化为更大规模的生产环境的真实流量。在生产中,我们观察到CPU减少了30%(对于相同的流量)。
构建系统的现代化
为了使迁移到.NET核心成为可能,一项重要的工作是对我们的构建系统进行现代化改造。
以前,我们使用的是一个内部构建系统,其工具链与.NET Core不兼容。因此,在我们的案例中,第一步是使构建系统现代化。我们迁移到一个较新的现代构建系统,主要使用Visual Studio工具链与MSBuild和dotnet支持。新的工具链支持.NET框架和.NET核心,并给予我们所需的灵活性。
最终,对构建系统现代化的投资,虽然一开始很困难,但它极大地提高了我们的生产力,构建速度更快,项目更容易创建和维护。
大局观
每次.NET升级都会有许多改进,即使Graph团队没有做任何明确的工作来提高性能。每个新的.NET版本都会改进低级别的运行时API、通用算法和数据结构,从而导致CPU周期和GC工作的下降。对于像微软图形这样的计算约束服务,使用新的运行时和算法来降低时间和空间的复杂性是至关重要的,也是使服务快速和可扩展的最有效的方法之一。在.NET团队的朋友的帮助下,我们已经能够提高吞吐量,减少延迟开销和计算运营成本。
迁移的另一个原因是为了使代码库现代化。一个现代化的代码库可以吸引人才(招聘),并使我们的开发人员能够使用更新的语言功能和API来编写更好的代码。像.NET Core中引入的spans这样的结构是无价之宝。我使用spans的常见方式之一是进行字符串操作。在旧的.NET代码库中,字符串操作是一个常见的陷阱。旧的模式往往会导致字符串分配的爆炸,因为无休止的串联会给GC带来压力,最终反映在更高的CPU成本上。而开发者甚至没有意识到这种分配的真正成本和影响。.NET Core中引入的Spans和string.Create给了我们一个操作字符串的工具,避免了堆上不必要的字符串分配的成本。
此外,我们依靠可观察性工具来监测在CPU、内存、文件和网络I/O等维度部署的代码的成本。这些工具帮助我们识别退步和机会,以改善处理延迟、运营成本和可扩展性。
我们通过新的API和C#功能取得了非常显著的效益:
- 通过阵列池减少缓冲区分配。
- 用内存和跨度相关类型减少缓冲区和字符串的分配。
- 通过静态匿名函数,减少了从封闭的上下文中获取状态的委托分配。
- 用ValueTask减少任务分配。
- 用nullable删除整个代码库中多余的null检查。
- 用null-coalescing赋值或使用声明来编写简洁的代码,仅举两例。
还有许多其他的改进,没有被这个清单所涵盖,其中包括算法和数据结构,以及重要的架构和基础设施的变化。归根结底,.NET核心和语言特性使我们能够提高生产力,并编写能够降低时间和空间复杂性的算法和数据结构,这对于实现我们的长期目标至关重要。
最后但并非最不重要的是,.NET核心使我们的服务可以在Windows和Linux中运行,并使我们能够处于领先地位,以快速的速度进行创新,传输协议如HTTP/3和gRPC。
迁移指导
本节描述了从ASP.NET迁移到ASP.NET Core环境所采用的策略,作为高级别的指导。
第1步 - 构建现代化
第一个先决条件是一个允许你构建.NET Framework和.NET Core程序集的构建系统,如果这还不是的话。
对于Graph团队来说,构建系统的现代化,不仅使迁移到.NET Core成为可能,而且还极大地提高了我们的生产力,构建速度更快,项目更容易创建和维护。
第2步-架构准备就绪
有一个好的架构来执行迁移是很重要的。让我们用一张图来说明我们要经历的三个主要阶段:

- 在第一阶段,我们有ASP.NET网络服务器组件和所有针对.NET框架的库(黄色)。
- 在第二阶段,我们有两个网络服务器程序集,每个都以各自的.NET运行时间为目标,而库现在以.NET标准为目标(蓝色)。这样就可以进行A/B测试。
- 在第三阶段,我们有一个网络服务器程序集,所有库都以.NET核心为目标(绿色)。
如果你的解决方案还没有被分解成多个程序集(阶段1),现在是一个很好的机会。ASP.NET程序集应该是Web服务器的一个非常薄的存根,将应用程序从主机中抽象出来。这个ASP.NET程序集应该是针对主机的,并引用下游的库来实现各个组件,如控制器、模型、数据库访问等等。有一个关注点分离的架构模式是很重要的,因为这有助于简化依赖链和迁移工作。
在我们的服务中,这是通过一个单一的HTTP应用处理程序来完成的,处理传入的请求,它是针对主机的。处理程序将传入的HttpContext 转换为与主机无关的等效对象,并将其传递给下游组件,这些组件使用该对象来读取传入的请求并写入响应。我们使用接口来抽象每个主机环境使用的传入HttpContext ,分别是System.Web.HttpContext和Microsoft.AspNetCore.Http.HttpContext。此外,我们在下游组件中实现了路由规则,与主机无关,这也简化了迁移工作。该服务没有UI或视图组件。如果你有一个带有MVC和模型绑定的视图组件,解决方案必然会更加复杂。
第3步 - .NET框架的依赖性清单
创建一个服务所使用的所有只属于.NET框架的依赖关系的清单,并确定所有者,以便在需要时与他们接触。
根据相关性和投资回报率对每个依赖关系进行分类。使用和维护依赖关系会带来一些包袱和税收,它们最好是值得的。通常情况下,一个好的依赖关系要遵守以下原则:
- 除了.NET运行时或扩展外,它不带有隐含的依赖性。
- 它解决了一个不容易解决的有意义的问题,或者逻辑非常敏感,不希望出现重复。
- 它具有良好的质量、可靠性和性能,特别是在热路径中出现时。
- 它被积极维护。
如果这些前提中的任何一个没有得到满足,那么可能是时候找到一个替代方案了,要么找到另一个可以做这个工作的依赖关系,要么实现它。
大多数流行的库已经瞄准了.NET标准,许多甚至瞄准了.NET核心。对于那些专门针对.NET框架的库来说,在.NET标准中构建它们往往已经在所有者的考虑范围之内了。如果有要求的话,大多数库的所有者都非常愿意做这样的工作。与库的所有者接触,了解提供.NET核心兼容版本的时间表。
第4步 - 摆脱项目库中的.NET框架依赖性
开始一个接一个地迁移依赖关系,转移到.NET标准中的相应部分。如果解决方案中有许多项目,就从依赖链最底层的项目开始,采用自下而上的方法,因为通常这些项目的依赖数量最少,更容易迁移。
以.NET框架为目标的项目可以继续这样做,而迁移工作正在进行中。一旦一个项目不再引用任何.NET框架的依赖关系,就让它以.NET标准为目标。
第5步-避免被阻断
如果服务有遗留问题或规模较大,很可能你会发现依赖关系被埋没,难以摆脱。不要气馁,可以考虑以下可能:
- 志愿帮助业主将依赖关系构建为.NET标准,以解除自己的封锁。
- 将代码分叉并在你的仓库中构建为.NET标准,作为一个临时解决方案,直到有一个兼容的版本。
- 将依赖关系作为单独的控制台应用程序或后台服务来运行,并使用.NET框架。现在你的服务可以在ASP.NET Core中运行,而控制台应用程序或后台服务在.NET框架中运行。
- 作为最后的手段,尝试从一个.NET核心项目中引用该依赖关系,包括你的.NET框架ProjectReference或PackageReference与
NoWarn="NU1702"。.NET核心运行时使用了一个兼容性垫片,允许你加载和使用一些.NET框架组件。然而,不建议将此作为永久性措施。这种方法必须经过详尽的测试(在运行时),因为即使构建成功,也不能保证程序集是兼容的(在所有代码路径中)。
在微软图形迁移的案例中,我们在不同的时间和不同的依赖关系上使用了所有这些选项。目前,我们仍然将一个控制台应用程序作为.NET Framework运行,并在服务中使用兼容shim加载一个.NET Framework程序集。
第6步 - 为ASP.NET Core创建新的webserver项目
为ASP.NET Core创建一个新的项目,与你当前的ASP.NET Framework项目并排,并有同等设置。新的ASP.NET Core项目默认使用Kestrel。它非常好,是大多数.NET团队投资的地方。它是他们的跨平台网络服务器。不过,你也可以考虑其他选择,比如HTTP.sys、IIS,甚至NGINX。
确保启用.NET核心中较新的性能计数器。花点时间启用它们,特别是与CPU、GC、内存和线程池有关的。还要启用所选择的网络服务器的性能计数器(例如,请求队列)。当你开始推广时,这些将是很重要的,以检测任何回归或异常情况。
在这一点上,你应该已经完成了第二阶段(在我上面分享的图片中),并准备进行A/B测试和开始推广。
第7步 - A/B测试和推广计划
创建一个推广计划,允许在通过所有预生产关口后,以某种生产能力进行A/B测试(例如,将新的运行时间部署到一个规模集)。用真实的流量进行规模测试,是最终的关口和真理的时刻。
你可以使用以下启发式方法来衡量前后的应用效率,测量A/B位之间的差异:
Efficiency = (Requests per second) / (CPU utilization)
在第一次推出时,尽量减少有效载荷中引入的变化,以减少可能导致意外回归的变量数量。如果我们在有效载荷中引入太多的变量,我们就会增加引入其他bug的几率,这些bug可能与新的运行时无关,但仍会浪费工程师的时间来识别和根治它们。
一旦最初的推广在小范围内成功,并且经过审查,就计划按照现有的安全部署做法,使用渐进式推广来启用新的位。重要的是要遵循逐步推广的原则,这样可以及时发现和缓解随着数量和规模的增加可能出现的问题。
第8步--在所有项目中瞄准.NET核心
一旦你的服务在ASP.NET Core中运行,并进行了大规模的部署和审查,现在是时候删除仍然在周围徘徊的.NET框架的最后碎片。删除ASP.NET的Web服务器项目,并将所有项目库明确转移到.NET核心,而不是.NET标准,这样你就可以开始使用更新的API和语言功能,使开发人员能够编写更好的代码。就这样,你已经成功地通过了第三阶段。
升级提示
一些主要的学习内容和升级提示的应用。
URI编码中的怪异现象
该服务的一个核心功能是解析传入的URI。多年来,我们最终在整个代码库中有不同的点,对传入请求的编码方式有硬性的假设。当我们从ASP.NET转移到ASP.NET Core时,很多假设都被违反了,导致了许多问题和边缘案例。经过很长时间,几次修复和分析,我们整合了以下规则,用于将ASP.NET Core的路径和查询转换为代码中不同部分需要的旧ASP.NET格式。
-
拒绝的百分比编码的ASCII字符,按主机计算
ASP.NET Core ASP.NET 路径 %00 %00至%19,以及%7F 查询 无 无 -
自动解码的百分比编码的字符,由主机负责
ASP.NET核心 ASP.NET 路径 无 没有多字节的UTF8字符,每一个非拒绝的ASCII字符,除了。%20, %22, %23, %25, %3c, %3e, %3f, %5b, %5d, %5e, %60, %7b, %7c, %7d 查询 无 无
用.NET 6启用动态PGO
在.NET 6中,我们启用了动态PGO,这是.NET 6.0中最令人兴奋的功能之一。PGO可以使.NET 6.0应用程序的稳态性能最大化,从而使其受益。
动态PGO是.NET 6.0中的一项选择功能。你需要设置3个环境变量来启用动态PGO。
set DOTNET_TieredPGO=1.该设置利用方法的初始Tier0编译来观察方法行为。当方法在第1层被重新编辑时,从第0层执行中收集的信息被用来优化第1层的代码。set DOTNET_TC_QuickJitForLoops=1.此设置使包含循环的方法能够分层。set DOTNET_ReadyToRun=0..NET的核心库默认启用了ReadyToRun。ReadyToRun允许更快的启动,因为有更少的JIT编译,但这也意味着ReadyToRun图像中的代码不经过Tier0剖析过程,这使得动态PGO成为可能。通过禁用ReadyToRun,.NET库也参与了动态PGO过程。
这些设置使Azure AD Gateway的应用效率提高了13%。
其他参考资料
更多的学习内容,请参考我们Azure AD网关姐妹团队发布的以下博客:
总结
每一个新的.NET版本都会带来巨大的生产力和性能改进,这些改进将继续帮助我们实现建立可扩展服务的目标,这些服务具有高可用性、安全性、最小的延迟开销和最佳路由,同时具有最低的运营成本。