微服务平台的最佳选择并非Java

发表时间: 2020-11-06 10:55

微服务当下非常流行,即使在传统的 IT 企业中也是如此。然而通常情况下微服务使用诸如 Java 之类的语言来实现,而这些语言诞生于 90 年代初,并且专为开发单体应用而设计。你还记得旧的大型应用服务器吗?

如果忽略近十年来发展的新的开发平台,在采用微服务时可能导致非最优的结果以及较高的运行成本。

在过去的十年中出现了很多新的编程平台,所有这些平台的目的都是为“现代分布式计算”提供更好的支持,而“现代分布式计算”正是微服务的基础。此类技术有望优化基础架构成本,并有效解决数字革命带来的工作负载不断增加的问题。

此外,随着容器的出现,开发人员可以“用他们想要的任何语言编写并在任何地方运行”,从而使最初由 Java 实现的“一次编写,到处运行”变得不那么重要了。

当采用基于微服务的架构时,忽略应用程序开发领域的此类进步可能会导致非最优结果。

这篇文章的重点是其中两种技术:Node 和 Go。为什么是这二者?令我着迷的一个奇怪的事实是:它们的生日相同,我的意思是几乎是同一天。这也许不是偶然。

Java 发布后的 15 年,Node和Go诞生

2009 年 11 月 8 日,Ryan Dahl 首次发布了 Node,这是一个在服务器上运行 Javascript(现在还包括 Typescript)的开源平台。

两天后,Google 发布了一种新的开源编程语言 Go。从那时起,Node 和 Go 一直稳步发展,现在已经成为主流技术。根据 StackOverflow2020 开发人员调查,Node 是最受欢迎的平台,而 Go 在 最受欢迎的编程语言中排名第三。

考虑到两种技术有本质不同,这可能被视为巧合。但是在如此接近的诞生日期之后,也许还有一些深刻的东西,而这今天仍然与我们有关。

并发模式下,微服务更具伸缩性

到 2009 年,数字服务需求的指数增长已经成为事实。但是,负载的指数增长无法通过基础设施的类似指数增长来解决。为了应对这一挑战,一种新的体系结构模式出现了:基于多核处理器(例如 x86)的水平可扩展分布式系统,而这是微服务化的基础。

使用微服务,你可以更好地优化许多小任务的并发处理,这不是在设计 Java 和 C#等编程语言时的目标。

只要能够“同时”处理许多小任务,这种架构就可以以可持续的成本进行扩展,从而最大程度地利用那些商用多核处理器的 CPU 和内存资源。

Java 等传统编程语言诞生于不同的时代,其设计并未考虑到水平可扩展的分布式体系结构。上世纪 90 年代的应用程序是单体的:单个服务器运行单个进程。

2009 年,新的需求是在许多小型计算机上同时运行许多小任务(大型并发 / 并行系统)。因此这种需求和已有技术上显然存在着不匹配。

虽然 Node 和 Go 着眼于不同的方向,但他们都可以解决这种不匹配问题。

Node 和 Go 的共同点:对并发的原生支持

Node 和 Go 同时诞生可能是一个巧合,尤其是它们有非常多的不同点。

但是,如果我们看一下它们的共同点,可能就会觉得这不是巧合了:对并发的原生支持。这就是为什么可以将它们视为应对现代分布式计算所带来的新挑战的两种不同方式:对并发的强大支持。

为什么并发在分布式架构中如此重要

让我们看一个典型的程序,它与数据库、REST 服务进行交互,也可能与存储进行交互。

这样的程序通常做什么?大多数情况下,它保持空闲状态,等待其他地方执行的某些 I/O(输入 / 输出)操作。这称为 I/O 绑定,因为实际上单位内处理的请求受 I/O 操作响应速度的限制。

由于物理原因,I/O 操作的速度比 CPU 执行的速度要慢几个数量级。访问主存储器中的数据大约需要 100 纳秒。同一数据中心内的数据往返大约需要 250,000 纳秒,不同区域之间的数据往返则超过 2,000,000 纳秒。结果是对单个请求的处理绝大多数时间是在等待 I/O 操作完成,这浪费了大部分 CPU 周期。

在分布式体系结构中,如果要优化基础设施的使用并使其成本最小化,那么并发至关重要。

那么,这对我们的计算能力意味着什么呢?这意味着除非我们确保 CPU 可以“同时”处理多个请求,否则它将一直不能得到充分利用。而这正是并发的全部意义所在。

这不是一个新问题。传统上,在 Java 世界中,这是应用程序服务器的任务。但是应用服务器不适用于分布式和水平可扩展的体系结构。这就是 Node 和 Go 之类的技术可以发挥作用的地方。

并发和并行很相似,但概念不同。我在这里使用并发,因为并发在这种情况是真正重要的。

Node 和并发

Node 是单线程的。因此,所有操作都在一个线程中运行(嗯……几乎所有操作,但这与我们所讨论的内容无关)。那么它如何支持并发呢?秘密在于 Node 线程也不会在 I/O 操作时阻塞。

在 Node 中,当执行 I/O 操作时,程序不会停下等待 I/O 响应。相反,它为系统提供了一个回调函数(当 I/O 返回时必须执行的操作),然后立即执行下一个操作。I/O 完成后,将执行回调函数,恢复程序的处理逻辑。

因此,在请求 / 响应场景中,我们触发 I/O 操作并释放 Node 线程,以便同一 Node 实例可以立即处理另一个请求。


在上面的示例中,第一个请求 Req1 运行一些初始逻辑(第一个深绿色),然后启动 I/O 操作(I/O 操作 1.1),该操作指定在 I/O 完成时必须调用的回调函数(函数 cb11)。

此时,对 Req1 的处理将暂停,Node 可以开始处理另一个请求,例如 Req2。当 I/O 操作 1.1 完成时,Node 准备好恢复对 Req1 的处理,并将调用 cb11。cb11 本身将通过 cb12 作为回调函数来启动另一个 I/O 操作(I/O 操作 1.2),该操作将在第二个 I/O 操作完成时被调用。依此类推,直到 Req1 处理结束,并将响应 Resp1 发送回客户端。

以这种方式,Node 可以通过单线程同时(即并发)处理多个请求。非阻塞模型是 Node 中并发的关键。

但是,如果使用单线程,则意味着我们不能利用多核(对于多核方案,可以使用 Node 集群,但是沿着这条路走将不可避免地给整个解决方案增加一些复杂性)。

要注意的另一个方面是,非阻塞模型意味着使用异步编程风格,除非进行适当管理,这种编程风格一开始可能导致难以理解,并且可能导致复杂变得代码,即所谓的“回调地狱”。

Go 语言和并发

Go 并发的方法基于 goroutine,它们是 Go 运行时管理的轻量级线程,它们通过通道(channel)相互通信。

程序可以启动许多 goroutine,而 Go 运行时将根据其优化算法在 Go 可用的 CPU 内核上调度它们。goroutine 不是操作系统任务,它们需要的资源少得多,并且可以被快速大量生成(有一些使用 Go同时运行数十万甚至数百万个goroutine 的例子)。

Go 也是非阻塞的,但这都是通过 Go 运行时在后台完成的。例如,如果一个 goroutine 触发了网络 I/O 操作,则其状态将从“运行”变为“等待”,并且 Go 运行时调度器将选择另一个 goroutine 来执行。

因此,从并发角度来看这和 Node 所做的类似,但其中有两个主要区别:

  • 它不需要任何回调机制,并且代码执行顺序与常规的同步逻辑相同,这通常更容易实现
  • 它是多线程的,可以无缝利用 Go 运行时可用的所有 CPU 内核

在上面的示例中,Go 运行时有 2 个可用内核。所有处理器都用于处理传入的请求。每个传入的请求均由一个 goroutine 处理。

例如,Req1 由 Core1 上的 goroutine gr1 处理。当 gr1 进行 I/O 操作时,Go 运行时调度器会将 gr1 变为“等待”状态并开始处理另一个 goroutine。I/O 操作完成后,gr1 变为“就绪”状态,Go 调度器将尽快恢复其运行。

Core2 也发生类似的情况。因此,如果我们看一个单一的核,我们会看到它和 Node 比较类似。goroutine 状态的切换(从“运行”到“等待”再到“就绪”再到“运行”)由 Go 运行在后台执行,并且代码是简单按顺序执行的语句流,这和 Node 所使用的基于回调的机制不同。

除了上述功能外,Go 还提供了一种基于通道和互斥锁非常简单而强大的通信机制,该机制可以实现不同 goroutine 之间的平滑同步和调度。

然而不止并发

既然我们已经了解了 Node 和 Go 如何原生支持并发,那么我们也可以看看其他的使用这两种高效工具的原因。

Node 可以用一种编程语言来实现前端和后端

Javascript/Typescript 在前端领域占主导地位。软件商店中的前端软件几乎都大量使用 Javascript/Typescript 技术。

但是,如果您还需要构建后端怎么办?使用 Node,您可以利用相同的编程语言,相同的结构和相同的思想(异步编程)来构建后端。甚至在无服务架构中,Node 都扮演着核心角色,它是所有主要云提供商为其 FaaS 产品(函数即服务,如 AWS Lambda、Google Cloud 和 Azure functions)提供支持的第一个平台。

前端和后端之间的轻松切换可能是 Node 取得令人难以置信的成功的原因之一,它产生了非常多的软件包(你几乎可以找到任何需要的 Node 库)和超级活跃的社区。

同时,Node 并不能有效地支持所有类型的后端程序。例如,考虑到它的单线程性质,CPU 密集型的程序不适用于 Node。因此,你不应该陷入“一种语言适合所有情况”的陷阱。

在 Node 生态系统中有大量可用的软件包,所以在使用时必须经常检查所导入包的质量和安全性。但这在我们利用外部库的任何时候都可能是正确的:生态系统越广,越要关注质量和安全性。

在许多情况下可以选择使用 Node,特别是对于 I/O 瓶颈的并发情形,使用 Node 还可以最大限度地利用你可能已经拥有的 Javascript/Typescript 技能。

Go 重新发掘了简单性和高性能

Go 很简单。它只有 25 个保留字,语言规范为 84 页,其中还包括大量示例。作为对比,Java 规范文档第二版超过 350 页。

简单性使其易于学习。简单性有助于编写易于理解和维护的代码。使用 Go 时,通常只有一种方法可以完成需要做的事情,这并不是要阻碍创造力,而是使阅读和理解代码变得更容易。

简单性也产生一些限制。诸如泛型或异常之类的概念根本不在语言中,因为作者并不认为它们是必要的(即使泛型似乎将要出现)。另一方面,一些真正有用的工具(例如垃圾收集)则是核心设计的一部分。

Go 还涉及运行时性能和资源的有效利用。Go 是一种强类型的编译型语言,可用于构建快速高效运行的程序,特别是在我们可以利用多核并发功能的情况下。

Go 还可以生成较小的自包含二进制文件。包含 Go 可执行文件的 docker 镜像可能包含相同 Java 程序的 docker 镜像小得多,这是因为 Java 需要 JVM 才能运行,而 Go 可执行文件是独立的(根据基准测试,对于优化后的“Hello World”的 docker 镜像,Java 8 程序为 85 MB,而 Go 程序仅为 7.6MB(一个数量级的差别)。镜像大小很重要:它可以加快镜像构建和拉取时间,降低网络带宽要求,并提高对安全性的控制。

其他新技术

新产生的技术并非只有 Node 和 Go,近年来产生的其他技术有可能使传统的单体应用受益。

Rust:这是一种开源语言,由 Mozilla 支持,该语言于 2010 年推出(比 Node 和 Go 早一年)。Rust 的主要目标是使用更安全的编程模型来优化 C/C++ 的性能,即更少地陷入令人讨厌的运行时错误。

Rust 引入的新思维方式,特别是围绕拥有 / 借用内存的理念,通常被认为学习曲线十分陡峭。如果性能非常关键,那么 Rust 绝对是一个值得考虑的选择。

Kotlin:它是一种在 JVM 上运行的语言,该语言由 JetBrain 开发并于 2016 年发布(2017 年 Google 宣布支持 Kotlin 作为 Android 的官方语言)。它比 Java 更为简洁,并且在原始设计概念中就包含如函数式编程和协程等,这使其成为现代编程语言的一员。可以将其视为 Java 的自然演变,对于有 Java 编程经验的开发人员而言,其入门门槛较低。

GraalVM:这是一种针对 Java 和其他基于 JVM 的编程语言的有前途的新方法。GraalVM 原生映像(GraalVM Native Image)允许将 Java 代码编译为本机可执行的二进制文件。这样可以生成较小的映像,并在启动和执行时均可提高性能。该技术还很年轻(于 2019 年发布),目前显示出一些局限性。鉴于 Java 的广泛使用,该技术可能会有重大改进并逐渐走向成熟。

结论

软件世界正在快速发展。新的解决方案经常出现。有些技术从来没有走到最前列,有些经过一段时间的炒作,然后从主流技术中被淘汰。选择合适的解决方案并不容易,而且需要在新平台与经过实战考验的稳定性和专业性之间进行权衡。

事实证明,Node 和 Go 都是可用于企业级的技术。与传统的面向对象的编程语言相比,它们有潜力带来显著的收益,特别是它们提高了容器化分布式应用程序的效率。它们得到了强大的社区支持,并拥有广泛的软件生态。

尽管企业必须继续使用和支持 Java 之类的传统平台——毕竟他们的生产代码库跨越的范围太大了,但也强烈建议他们也开始拥抱相对较新的工具(例如 Node 和 Go),因为这些新工具可以使他们能够获得现代分布式计算的所有好处。

原文链接:

https://hackernoon.com/microservices-deserve-modern-programming-platforms-java-may-not-be-the-best-option-1v5z3tai

延伸阅读:

一个微服务业务系统的中台构建之路-InfoQ

30+微服务构建的顶级工具清单-InfoQ

Helidon项目教程:如何使用Oracle轻量级Java框架构建微服务-InfoQ


关注我并转发此篇文章,私信我“领取资料”,即可免费获得InfoQ价值4999元迷你书,点击文末「了解更多」,即可移步InfoQ官网,获取最新资讯~