linux工程师,RHCE
分类: 系统运维
2020-11-18 08:45:40
Peter Nagy 和我在 2020 年 8 月的甲骨文 Groundbreakers Tour 2020 LATAM 大会上发表一篇论文,题为《Go Java, Go!》。我们在本文中提出一个问题:“Java 微服务能像 Go 一样快吗?”为此,我们创建了一系列微服务并进行了基准测试,并在会议上展示了我们的成果。但其中还有不少可以探索的空间,因此我们决定将在本文中进一步探讨。
1. 背景介绍
我们希望通过实验了解 Java 微服务在运行速度上能否达到 Go 微服务的水平。目前,软件行业普遍认为 Java 已经过于陈旧、缓慢且无聊。而 Go 则成了快速、崭新以及酷炫的代名词。真是这样吗?我们想从数据的角度看看这样的印象是否站得住脚。
我们希望建立一个公平的测试,因此创建了一项非常简单的微服务,其中不含外部依赖项(例如数据库),而且代码路径非常短(仅处理字符串)。我们在其中包含有指标及日志记录,因为似乎一切微服务都或多或少包含这些内容。另外,我们使用了小型、轻量化的框架(Helidon for Java 以及 Go-Kit for Go),两袖清风尝试了 Java 的纯 JAX-RS。我们也尝试了不同版本的 Java 与不同 JVM。我们对堆大小及垃圾收集机制做出基本调整,并在测试运行前对微服务进行了预热。
2. Java 的发展历史
Java 由 Sun Microsystems 公司开发,后被甲骨文所收购。其 1.0 版本发布于 1996 年,目前的最新版本是 2020 年的 Java 15。Java 当前的主要设计目标,在于实现 Java 虚拟机及字节码的可移植性,外加带有垃圾回收的内存管理机制。时至今日,Java 作为一种开源语言仍是全球最受欢迎的语言选项之一(根据 StackOverflow 及 TIOBE 等来源)。
下面来聊聊“Java 问题”。人们对于它速度缓慢的印象其实更多是种固有观念,而不再适应当下的事实。如今的 Java 甚至拥有不少性能敏感区,包括存储对象数据堆、用于管理堆的垃圾收集器,外加准时化(JIT)编译器。
多年以来,Java 曾先后使用多种不同的垃圾收集算法,包括串行、并行、并发标记 / 清除、G1 以及最新的 ZGC 垃圾收集器。现代垃圾收集器旨在尽可能减少垃圾收集造成的暂停时长。
甲骨文实验室开发出一款名为 GraalVM 的 Java 虚拟机,其使用 Java 编写而成,具有新的编译器外加一系列令人兴奋的新功能,包括可以将 Java 字节码转换为无需 Java 虚拟机即可运行的原生镜像等。
3. Go 的发展历史
Go 语言由谷歌的 Robert Griesemer、Rob Pike 以及 Ken Thomson 开发而成。他们几位也是 UNIX、B、C、Plan9 以及 UNIX 视窗系统等项目的主要贡献者。作为一种开源语言,Go 的 1.0 版本发布于 2012 年,2020 年最新版本为 1.15。Go 语言的本体、采用速度以及工具生态系统的发展都相当迅猛。
Go 语言受到 C、Python、JavaScript 以及 C++ 的影响,已经成为一种理想的高性能网络与多处理语言。
截至我们发布主题演讲时,StackOverflow 上共有 27872 个带有“Go”标签的问题,Java 则为 1702730 个。
Go 是一种静态类型的编译语言,其语法类似于 C,且拥有内存安全、垃圾回收、结构化类型以及 CSP 样式并发(通信顺序过程)等功能特性。Go 还使用名为 goroutine 的轻量级进程(并非操作系统线程),外加各进程间用于通信的通道(类型化,FIFO)。Go 语言不提供竞态条件保护。
Go 是众多 CNCF 项目的首选语言,例如 Kubernetes、Istio、Prometheus 以及 Grafana 等皆是由 Go 语言编写而成(或者大部分是)。
Go 语言在设计上强调快速构建与快速执行。到底是两个空格还是四个空格?Go 语言表示不用麻烦,无所谓。
与 Java 相比,我将个人体会到的 Go 语言优势整理如下:
但 Go 当然也不完美。与 Java 相比,我认为 Go 存在以下问题:
4. 负载测试方法
我们使用 JMeter 进行负载测试。测试多次调用服务,并收集关于响应时间、吞吐量(每秒事务)以及内存使用情况的数据。在 Go 方面,我们主要收集常驻集大小,Java 方面则主要跟踪原生内存。
在多项测试中,我们都将 JMeter 与被测应用程序放置在同一台计算机上运行。经过对比,我们发现在其他机器上运行 JMeter 几乎不会对结果造成任何影响。后续在将应用程序部署到 Kubernetes 中时,我们会考虑将 JMeter 运行在集群之外的远程计算机之上。
在进行测试之前,我们使用 1000 项服务调用对应用程序进行了预热。
应用程序本体的源代码以及负载测试定义请参见 GitHub repo:
5. 首轮测试
在第一轮测试中,我们在小型机器上运行测试,搭载了 2.5 GHz 双核英特尔酷睿 i7 的笔记本电脑,具有 16 GB 内存并运行 MacOS。我们运行了 100 个线程,每个线程 10000 个循环,再额外加个 10 秒的启动时间。Java 应用程序运行在 JDK 11 与 Helidon 2.0.1 之上。Go 应用程序则使用 Go 1.13.3 进行编译。
测试结果如下:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务 /秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
Golang | 是 | 否 | 5.79 | 15330.60 | 5160KB / 15188KB |
Golang | 否 | 否 | 4.18 | 20364.11 | 5164KB / 15144KB |
Golang | 否 | 是 | 3.97 | 21333.33 | 10120KB / 15216KB |
Java (Helidon) | 是 | 否 | 12.13 | 8168.15 | 296376KB / 427064KB; 提交 = 169629KB +15976KB (NMT); 保留 =1445329KB +5148KB (NMT) |
Java (Helidon) | 否 | 否 | 5.13 | 17332.82 | 282228KB / 430264KB; 保留 =1444264KB +6280KB; 提交 =166632KB +15884KB |
Java (Helidon) | 否 | 是 | 4.84 | 18273.18 | 401228KB / 444556KB |
我们宣布,Go 成为首轮测试的获胜者!
以下为根据这些结果得出的观察结论:
6. GraalVM 原生镜像
GraalVM 提供原生镜像功能,使您能够使用 Java 应用程序并在实质上将其编译为原生可执行代码。根据 GraalVM 项目网站的介绍:
该可执行文件包含应用程序类、依赖项中的类、运行时库类以及 JDK 中的静态链接原生代码。其并非运行在 Java 虚拟机之上,而是包含必要组件,例如来自不同运行时系统(也被称为「基层虚拟机」)的内存管理、线程调度等功能。基层虚拟机代表的是各运行时组件(例如反优化器、垃圾收集器、线程调度等)。
在添加 GraalVM 原生镜像(原生镜像由 GraalVM EE 20.1.1——JDK 11 构建而成)之后,首轮测试结果如下:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务 /秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
Golang | 是 | 否 | 5.79 | 15330.60 | 5160KB / 15188KB |
Golang | 否 | 否 | 4.18 | 20364.11 | 5164KB / 15144KB |
Golang | 否 | 是 | 3.97 | 21333.33 | 10120KB / 15216KB |
Java (Helidon) | 是 | 否 | 12.13 | 8168.15 | 296376KB / 427064KB; 提交 = 169629KB +15976KB (NMT); 保留 =1445329KB +5148KB (NMT) |
Java (Helidon) | 否 | 否 | 5.13 | 17332.82 | 282228KB / 430264KB; 保留 =1444264KB +6280KB; 提交 =166632KB +15884KB |
Java (Helidon) | 否 | 是 | 4.84 | 18273.18 | 401228KB / 444556KB |
Native Image | 是 | 否 | 12.01 | 7748.27 | 18256KB / 347204KB |
Native Image | 否 | 否 | 5.59 | 15753.24 | 169765KB / 347100KB |
Native Image | 否 | 是 | 5.22 | 17837.19 | 127436KB / 347132KB |
在这种情况下,与运行在 JVM 上的应用程序相比,我们发现使用 GraalVM 原生镜像并不会在吞吐量或者响应时间等层面带来任何实质性的改善,但内存占用量确实有所减少。
以下是测试期间的响应时间图表:
首轮响应时间图
请注意,在所有三种 Java 变体当中,第一批请求的响应时间要长得多(蓝线相较于左轴的高度)而且在各项测试中,我们还看到一些峰值,其可能是由垃圾收集或优化所引起。
7. 第二轮测试
接下来,我们决定在更大的计算机上运行测试。在本轮中,我们使用台具有 36 个核心(每核心双线程)、256 GB 内存的计算机,并配合 Oracle Linux 7.8 操作系统。
与第一轮一样,我们仍然使用 100 个线程、每线程 10000 个循环,10 秒启动时间以及相同版本的 Go、Java、Helidon 以及 GraalVM。
下面来看结果:
应用程序 | 日志记录 | 预热 | 平均响应时间(毫秒) | 事务 /秒 | 内存(RSS)(开始/结束) |
---|---|---|---|---|---|
原生镜像 | 是 | 否 | 5.61 | 14273.48 | 28256KB / 1508600KB |
原生镜像 | 否 | 否 | 0.25 | 82047.92 | 29368KB / 1506428KB |
原生镜像 | 否 | 是 | 0.25 | 82426.64 | 1293216KB / 1502724KB |
Golang | 是 | 否 | 4.72 | 18540.49 | 132334KB / 72433KB |
Golang | 否 | 否 | 1.69 | 37949.22 | 12864KB / 70716KB |
Golang | 否 | 是 | 1.59 | 39227.99 | 16764KB / 76996KB |
Java (Helidon) | 是 | 否 | 7.38 | 11216.42 | 318545KB / 529848KB |
Java (Helidon) | 否 | 否 | 0.40 | 74827.90 | 307672KB / 489568KB |
Java (Helidon) | 否 | 是 | 0.38 | 76306.75 | 398156KB / 480460KB |
我们宣布,GraalVM 原生镜像成为第二轮测试的赢家!
下面来看本轮测试的响应时间图:
启用日志记录,但未经预热的测试运行响应时间
不使用日志记录也未经预热的测试运行响应时间
经过预热,但未使用日志记录的测试运行响应时间
第二轮的观察结果:
8. 第三轮测试:Kubernetes
在第三轮中,我们决定在 Kubernetes 集群上运行应用程序,借此模拟更为自然的微服务运行时环境。
在本轮中,我们使用包含三个工作节点的 Kubernets 1.16.8 集群,每个工作节点中包含两个核心(各对应两个线程)、14 GB 内存以及 Oracle Linux 7.8。在某些测试中,我们在变体上运行一个 Pod;在其他一些测试中,我们则运行一百个 Pod。
应用程序访问通过 Traefik 入口控制器实现,其中 JMeter 运行在 Kubernetes 集群之外。在某些测试中,我们也会尝试使用 ClusterIP 并在集群内运行 JMeter。
与之前的测试一样,我们使用 100 个线程、每线程 10000 个循环,外加 10 秒启动时间。
以下是各个变体的容器大小:
以下为本轮测试结果:
响应时间图表:
Kubernetes 测试中的响应时间
在本轮中,可以看到 Go 有时更快,而 GraalVM 原生镜像也经常取得领先,但二者的差异很小(一般低于 5%)。
9. 测试结论
纵观几轮测试与结果,我们得出了以下结论:
10. 未来展望