将Bing的工作流引擎迁移至.NET 5的可实施性说明

207 阅读17分钟

Bing运行着世界上最大的、最复杂的、高性能的、可靠的.NET应用程序之一。这篇文章讨论了升级到.NET 5的过程和所需的工作,包括我们取得的显著性能提升。

这个应用程序位于Bing架构堆栈的中间,负责成千上万的其他组件之间的协调,为所有查询提供结果。它也是必应之外的许多其他服务的核心。

拥有这个组件的团队被称为XAP("Zap")。自2008年以来,我一直是这个团队的成员,在我加入微软后不久(当时必应还是Live Search!)。2010年,我们把大部分的堆栈从C++迁移到了.NET框架。

将XAP迁移到.NET Core的工作始于2018年7月。我们咨询了已经做过这种迁移的团队,如Bing的用户体验层,以及.NET团队本身。

长期以来,XAP团队一直依赖与.NET团队的密切工作关系,特别是该团队的通用语言运行时间(CLR)部分。随着.NET Core的宣布,我们知道我们会想要迁移。预期的性能优势、开源性质和更快的开发周转是额外的激励。

这次迁移对我们的团队来说是一个无条件的成功,在性能和敏捷性方面的改进已经很明显。

XAP:一个初步的了解

为了提供一些背景,让我传达一个XAP架构的高层次图片。

三年前,Mukul Sabharwal在博客上详细介绍了他的团队如何将Bing的前端迁移到.NET Core。坐落在这个用户体验层之下的是一个高性能的工作流执行引擎,它管理着为Bing提供数据的各种后端之间的协调和沟通。这个中间层也被用于微软内部许多非Bing服务。

值得注意的是,Bing的前端团队已经转到了.NET 6预览版。他们通过.NET 6预览版1进入生产,现在是预览版4。他们已经看到了一些非常显著的改进,主要与新的crossgen2功能有关。

XAP团队有两个主要领域的所有权:

  1. 为Bing(以及其他与搜索相关的应用,如Sharepoint、Cortana、Windows Search等)运营这个实时网站的中间部分。
  2. 为我们的内部合作伙伴提供运行时间、工作流引擎、工具和SDK,以便他们在这个平台上自行构建和操作场景。

在Bing这个我们最大的工作负载中,XAP在全球范围内的一组数据中心运营着成千上万的机器。在每台机器上,XAP正在加载超过90万个工作流实例和530万个插件实例--所有这些都在一个具有50GB堆的过程中,加载2500个独特的组件,并对超过210万个方法进行JIT。

在最基本的方面,XAP的ApplicationHost是独立开发的插件(功能)的主机,被分组到一个称为工作流的有向无环图。工作流程由插件和其他工作流程组成。XAP的工作是尽可能有效和快速地执行这个工作流程。

plugin graph for Bing first page

一个典型的Bing查询将在几百毫秒内执行大约12000个节点(包括2000多个网络调用)。一台机器在峰值时通常每秒执行超过30,000个节点。 为了有效地执行,以及维持一个安全的系统,我们对插件作者实施了许多限制,例如。

  • 有限的API表面积。没有改变进程的API,没有线程,没有I/O,没有同步。这些能力都是通过工作流引擎和专门的插件(如Xap.HTTP)来管理的。
  • 不变的状态。插件不能引用易变的静态数据。它们的输出是不可变的,以避免其依赖的同步化。
  • 严格的监控。插件有严格的延迟要求,超时后我们将开始运行它们的依赖。经常失败的插件会被自动禁用。

运行时引擎也被高度优化以高效地执行这些插件。我们尽可能地避免线程同步,尽量减少内存分配,将大的对象集中起来以避免重复的大对象堆分配,并且我们生成代码以更有效地执行动态加载的插件。

性能结果

在发布到生产中之前,我们一直在多个测试机器上运行各种构建并仔细分析结果。一旦我们有了合理的信心,我们就扩大到小规模的生产实验。从十台运行在.NET 5上的随机机器开始,我们逐渐扩大实验范围,直到整个数据中心都在生产中运行.NET 5。

生产结果

2020年7月8日,我们开始在一个数据中心将.NET 5预览版6推广到生产中,这距离项目开始已有近两年时间。(此后,我们升级到了.NET 5的RTM版本)。

我们将其他数据中心的推出时间推迟了几周,以使我们有足够的时间监测稳定性和性能。

延迟

延迟是我们跟踪的主要指标之一。

一个数据中心的迁移特别引人注目:

Bing Latency Datacenter Migration

这里是对多种环境的高层次总结。这些数字是近似的,取自多个月的日平均数。它们衡量的是两个百分位数的改善百分比:

环境P95%的改进P99%改善率
A3%5%
B4%7%
C1%2%
D12%14%
E7%10%

这些数据中心之间有很大的差异,这可以用流量和机器配置的差异来解释。

开销延时

ApplicationHost为查询执行增加的总时间减少了11%。

Bing ApplicationHost Overhead Latency

CPU

总体的CPU使用率显示下降了约27%,当我们看一下BingFirstPageResults查询中非I/O插件占用的总CPU时间时,这种差异尤其明显。

Bing CPU Metric

垃圾收集

用计数器来衡量GC对整个构建的精确影响是很棘手的。不仅GC的实现发生了重大变化,而且我们还在.NET Core中应用了新的配置。

GC计数器中的时间百分比已经增加。

Bing Time In GC

它从平均的0.3%上升到了0.8%,相对来说很高,但绝对值却不高。

对此的一个解释是,我们在与CLR团队协商后,经过大量的测量,做出了一个改变,减少了Gen0预算的大小。这导致GCs更快,但更频繁。这直接促进了整个查询P99延迟的改善,但是它的代价是在GC中花费了更多的时间。这是一个积极调查的领域,看看我们是否可以进一步改善,在其他配置调整中也有一些有希望的线索。

异常情况

平均异常率显著下降。

Bing Exceptions

从平均7.8/秒到3.5/秒,提高了55%。

锁争用

管理的锁争用在峰值上有很大下降,但基线水平略高。

Bing Lock Contention

平均来说,它从645个争用/秒下降到410个争用/秒,改善了36%。这对于.NET核心改变了锁的算法,比.NET框架(会旋转一段时间)更快进入等待状态的事实来说,意义更大。因此,在.NET Core下的很大一部分争用可能在.NET Framework中根本就没有被计算在内。

启动时间

启动时间的减少是很重要的,因为它意味着在我们部署的时候,每个数据中心停机的时间更少(每天多次),导致峰值延迟更低,峰值负载时的可用性更高。

启动时间显著减少。

Bing Startup Time

启动时间主要由预热时间驱动,在接受真正的流量之前,我们通过系统运行非用户查询。预热时间是由CPU时间决定的。28%的下降与整个插件CPU使用率的下降密切相关。

线程池

我们衡量效率的方法之一是准备执行的插件在队列中等待空闲处理器的时间。这个指标也是由CPU使用率决定的,所以它不是一个纯粹的线程池效率的衡量标准。

BingThreadPool

P95的平均排队延迟下降了约31%。P99的平均排队延迟下降了约26%。

迁移方法

性能数字很好,但实现这些数字的工作又是怎样的呢?值得强调的是我们迁移的目标:

  1. 灵活地选择在.NET框架或.NET核心下加载的机器。这将使我们能够。
    • 根据基础设施的概念,如机器功能、规模单位、环境或数据分区,动态地来回切换。
    • 在生产中回滚,甚至在我们迁移后几个月。
    • 在我们的存储库中进行修改,而不干扰其他人的工作。通过确保代码在两个运行时下都能运行,没有人需要做任何特别的事情--我们在测试这两个运行时。
  2. 单一代码库
  3. 单一的二进制文件副本
  4. 避免了从一开始就把我们所有的合作伙伴迁移到目标netcore/netstandard。这本来是不可能的。

考虑到这些原则,我们采用了一种混合方法来建立和运行XAP平台:

  1. 继续在.NET框架4.7.2下建立平台。
  2. 使用ApiPort工具来验证API级别的兼容性。这确保了我们在平台中的所有.NET库调用也存在于.NET Core中。
  3. 开发了一个基于CoreCLR的自定义主机应用程序,动态加载和执行框架构建的二进制文件。

这种方法有助于简化这个项目的测试和部署方面,它允许我们所有的合作伙伴继续开发他们的方案,完全按照他们以前的方式,而不必一下子将所有东西迁移到一个新的平台。

我们最终会将我们所有的二进制文件迁移到直接针对netstandard2.0和.NET 5或.NET 6构建,不再需要混合方法。

挑战

在我们开始这项工作后不久,我们就意识到我们将面临许多挑战,而且与其他团队所经历的不同类型和规模。

一个二进制问题

XAP使用的相当多的程序集是服务启动或处理单一查询所需要的。如果不修复每个依赖关系,甚至不可能开始测试。有几十个这样的依赖关系。这是一个 "全有或全无 "的问题。

C++/CLI

当我们开始迁移过程时,.NET核心是2.0版本,它不支持C++/CLI。这些DLLs甚至无法加载。Bing使用了一些常见的基础设施组件,这些组件通过C++/CLI接口浮现给托管代码。

没有这些程序集,程序甚至不能启动自己,更不用说处理查询了。

在整个公司团队的帮助和合作下,我们将所有这些组件转换为使用P/Invoke接口。

即使在最新版本的.NET Core中支持C++/CLI项目,这些程序集也需要重新构建。由于我们已经使用了新的接口,所以我们没有必要再继续下去。

不兼容的代码

XAP运行时主要包括一个名为ApplicationHost的托管进程,以及我们提供给合作伙伴在我们平台上执行的所有库。

我们依赖许多托管库,这些库有一小部分使用.NET核心中不存在的API。

这方面的例子包括:

  • 使用.NET的MemoryCache库的内存缓存
  • 一个哈希计算库
  • .NET远程(WCF)
  • HTTP功能
  • 自定义程序集加载

每种情况都需要单独解决。在某些情况下,我们可以切换到.NET中的替代API。在其他情况下,我们需要升级库。通常,这导致了一长串升级后的依赖关系。

我们数百个合作伙伴的插件也使用了.NET核心中不存在的各种API。被破坏的独特API的数量相对较少--可能只有十几个。但是,拥有这些插件的插件和合作伙伴团队的数量是令人生畏的。我们与世界各地的800多名开发人员合作,对XAP团队来说,与所有这些人沟通是一个挑战。

因此,我们开发了自动工具,使用.NET团队的Apiort工具扫描所有插件。我们把这些作为所有插件作者必须经历的自动部署过程的一部分,首先是警告,然后是阻止错误。我们把最常见的不兼容问题和建议的修改放在一起,以使其符合要求。被其团队放弃的插件被永久禁用。

.NET错误和功能改变

由于我们建立了一些高度专业化的功能领域,XAP团队面临许多挑战。有时我们依赖未说明的假设,而.NET的内部功能变化导致我们需要解决的行为变化。在其他情况下,我们极端的规模和独特的架构带来了其他测试尚未发现的错误。

装载器

XAP依赖于一些非常特殊的功能,我们通过消耗合作伙伴的程序集来实现并排加载程序。这是我们隔离模型的一个基本部分。.NET的加载器的一些细微差别需要我们做详细的调查,我们最终升级到CLR开发人员,他们至少发现了两个bug(dotnet/runtime #12072dotnet/runtime #11895),其中一个是关键的堆叠溢出崩溃。我们也被要求对我们的代码做一些修改。

此外,由于我们的跟踪和调查,他们修复了一个影响我们的汇编解决性能的错误

我们独特的架构和规模使我们能够在别人发现之前就突出这些不明显的bug。

ETW和TraceEvent

另一个重大挑战是Microsoft.Diagnostics.Tracing.TraceEvent库。当ApplicationHost刚建成时,我们决定用ETW作为我们的日志机制,当时ETW技术还没有进入.NET框架,而且开源版本还处于早期阶段。我们把EventSourceTraceEvent 的代码分叉成我们自己的私有版本,在随后的十年里,它们出现了分歧。由于各种原因,一旦它们有了更多的可用版本,我们就很难迁移到官方版本,而且成本很高。

最后,我们迁移到了官方软件包,沿途发现了几个 错误

我们遇到的另几个有趣的 bug在.NET 5中得到了修复,它们与CLR的事件提供者有关。当我们的记录器代码翻转文件时,它需要取消对它所监听的事件的订阅,包括CLR事件。当记录器重新订阅新文件的事件时,它再也没有收到CLR的事件了。甚至外部工具,如PerfView,我们依靠它来进行调试和性能分析,也不能再接收这些CLR事件。这个错误之所以具有挑战性,主要是因为它很难重现,而且我们的使用模式可能有点不寻常。

性能计数器

XAP是非常注重指标的。我们在5亿个时间序列中收集60亿个事件/分钟。其中大部分是我们自己的,但我们依靠许多系统和.NET级别的计数器。在.NET Core 2.x中,这种基础设施是不存在的,我们在许多方面是盲目的。

从.NET Core 3.x开始,一些性能计数器被提供出来。我们已经要求再增加一些。请看这里这里,还有这里。其中许多是在.NET 5中添加的。

异常处理的错误

虽然严格来说,这不是一个由于迁移而产生的错误,但在调查性能时,它与我们的许多其他问题混在一起,我们在调查CLR性能的同时也处理了它。

在相当长的一段时间里,XAP一直注意到在查询执行中不寻常的地方出现了长时间的停顿,而这些地方并没有明显的原因。在CLR团队的帮助下,我们收集了一些蛛丝马迹,发现在一些非常特殊的边缘情况下,线程的暂停被异常处理机制阻断,从而导致磁盘I/O。这是一个公认的错误,但奇怪的是,所有相关的堆栈都来自一个插件,该插件几乎在每次查询时都会抛出异常(并捕捉到它们)。我们通过ETW事件看到,它是过程中最大的异常来源,并要求代码所有者在调用抛出异常的API之前做一些验证(这对他们来说是一个微不足道的修正)。

一旦合作伙伴团队部署,神秘的线程停顿问题就得到了解决,我们的高延迟查询的最大来源也清除了。CLR团队后来修复了这个错误。这张图给出了这个修复的影响,显示了非常高的延迟查询百分比的变化。

Bing high latency queries chart

GC的变化

对我们有帮助的一个重大变化是内存的解密方式。这一变化将解密移出了服务器GC的暂停时间,并做了一些其他的改变来提高性能。

HTTP栈

最具挑战性的过渡领域之一是我们的HTTP客户端堆栈。在世界范围内,我们的HTTP插件每秒被我们的合作伙伴调用超过750万次。.NET框架和.NET核心有非常不同的HTTP客户端堆栈。鉴于我们的重要目标之一是拥有一个单一的代码库,并能够在Framework和Core之间转换,只需重新启动一个进程,我们有非常重要的问题需要解决。

在.NET框架中,我们依赖于ServicePointManagerWebRequestHandler 中的功能,这些功能在.NET核心中都不存在。起初,有人建议转移到WinHttpHandler ,但这个类缺少了很多我们所依赖的功能。如果我们采用它,会对性能产生真正的影响,因为Bing的一些后端团队实现了负载平衡,而使用WinHttpHandler ,是无法实现的。最终的建议,SocketsHttpHandler ,只适用于.NET Core。

我们的情况可以用下面的表格来概括:

包含所需的功能与.NET框架兼容?与.NET核心兼容
WebRequestHandler是的是的。不兼容
WinHttpHandler不兼容
SocketsHttpHandler是的

我们不想转移到WinHttpHandler ,而最终陷入两败俱伤的境地。最后,我们创建了一个HTTP接口,动态加载WebRequestHandlerSocketsHttpHandler ,这取决于使用的是什么运行时间。这两个类都有类似的功能,但没有共享一个共同的接口,所以我们开发了自己的接口。

此外,在.NET框架中,一些TCP设置是通过ServicePointManager ,但在.NET核心中,它们是在SocketsHttpHandler 对象上设置的。这意味着在应用程序中需要更多的条件性代码。

扩展工程工作

由于这个庞大项目的许多部分都是二进制性质的,所以很容易被完全封锁,特别是在早期阶段。例如,当我们在等待C++/CLI库被迁移到P/Invoke时,我们只会在其他我们知道需要改变的地方工作。没有办法真正启动ApplicationHost并测试它,或者看看还有什么问题。像这样的问题有很多。当然,其他工作仍在同时进行;例如,建立自动化系统来分析我们合作伙伴的代码。

结论

总的来说,在XAP的方案中,.NET 5比.NET框架表现出了明显的性能改进。虽然还有更多的工作要做,但总体情况很清楚,.NET 5在大多数方面都有惊人的优势。

我们希望从XAP的迁移故事中得到的教训能够帮助指导微软的其他团队和更大的行业,因为他们正在考虑迁移到.NET 5及以后。