.NET微服务系统迁移至.NET6.0的故事

时间:2023-02-23 10:09:26

本次迁移涉及的是公司内部一个业务子系统,该系统是一个多样化的应用,支撑着公司的多个业务方向。目前,该系统由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

*迁移至.NET5.0: https://twitter.com/juanrodriguezce/status/1428070925698805771

*迁移至.NET6.0: https://wouterdekort.com/2022/05/25/the-*-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,欢迎大家加入。