You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

184 lines
16 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 24 | 跨层案例如何优化程序、OS和存储系统的交互
你好,我是庄振运。
我们前面几讲介绍了性能优化的原则和策略并且集中探讨了CPU、内存和存储三个最关键的领域。
今天我们来讲一个**比较复杂的JVM场景和超大延迟的性能问题**;这是本模块,也就是性能优化模块的最后一讲。
我们会一步步地探讨这个性能问题的表象、问题的重现、性能分析的过程和解决方案。这个性能问题的复杂性表现在它牵扯了计算机技术的很多层次——从最上层的应用程序到中间层JVM的机制再到操作系统和文件系统的特性最后还涉及到硬件存储的特点。
更重要的是这几个层次互相影响最后导致了平时我们不容易看到的严重性能问题——非常大的JVM卡顿。
今天我会把问题的核心和分析过程阐述清楚,而对于其他的一些背景和更多的性能数据,你可以参考我发表在[IEEE Cloud](https://ieeexplore.ieee.org/document/7820334)上的论文。
## 生产环境下偶尔很大的响应延迟是怎么回事?
我们先来看看这个性能问题的表象:就是在生产环境中,偶尔会出现非常大的响应延迟。
由于大多数互联网业务都是面向在线客户的例如在线游戏和在线聊天所以确保客户相应的低延迟非常重要。各种研究也都表明200毫秒延迟是多数在线用户可以忍受的最大延迟。因此确保低于200毫秒甚至更短的延迟已经成为定义的SLA服务水平协议的一部分。
鉴于Java的普及和强大功能当今的互联网服务中有很大一部分都在运行Java。Java程序的一个问题是JVM卡顿也就是大家常说的STWStop The World、JVMJava虚拟机暂停。根据我的经验尽管我们或许已经仔细考虑了很多方面来优化但Java应用程序有时仍会遇到很大的响应延迟。
这个STW的产生和JVM的运行机制是直接相关的。
Java应用程序在JVM中运行使用的内存空间叫**堆**。JVM负责管理应用程序在内存里面的对象。堆空间经常被GC回收垃圾收集这个过程是JVM操作的。Java应用程序可能在GC和JVM活动期间停止这就会给应用程序带来STW暂停。
这些GC和JVM活动信息很重要根据启动JVM时提供的JVM选项各种类型的相关信息都将记录到GC的日志文件中。
尽管某些GC引起的STW暂停众所周知比如JVM导致的Full GC但是我们在生产中发现其他因素比如**操作系统本身也会导致一些相当大的STW暂停**。
比如我举个例子。文中的图片就显示了一个STW暂停和GC日志这个暂停时间超过了11秒我就经常在生产环境中看到这样的STW暂停。
![](https://static001.geekbang.org/resource/image/98/21/9811fd17d7055b2f25308a36f729b921.png)
你注意一下图片中有较大的字体的那行里面显示了User、Sys和Real的计时分别对应着用户、系统和实际的计时。比如“Real=11.45”就表示实际的STW暂停是11.45秒钟。
在这个GC日志中这种暂停11秒钟的STW非常讨厌因为这样大的暂停是不能容忍的。而且这个问题很难理解完全不能用GC期间的应用程序活动和垃圾回收活动来解释。
从日志中我们也看到这个JVM的堆并不大只有4GB垃圾收集基本不会超过1秒钟。正如图中显示的那样用户和系统时间都可以忽略不计User和Sys的暂停时间分别是0.18秒钟和0.01秒钟但是实际上JVM暂停了11.45秒钟!
因此GC所做的工作量根本无法解释如此之大的暂停值。
## 搭建测试环境重现问题
为了搞清这个问题,我们做了彻底的性能分析和各种测试。为了去掉很多其他的干扰因素,以便方便根因分析,我们首先希望**实验室环境中重现该问题**,这样就比较方便从根本上解释原因。
出于**可控制性**和**可重复性**的考虑我们使用了自己设计的一个简单Java程序。为了方便对比我们根据**有没有后台背景IO活动**而测试了两种场景这里的后台背景IO就是各种磁盘IO。不存在后台IO的场景是基准场景而引入后台IO的另一场景将重现我们观测到的性能问题。
我们使用的Java程序的逻辑也很简单直白就是不断地分配和删除特定大小的对象。程序一直在不断分配对象当对象数目达到某阈值时就会删除堆中的对象。堆的大小约为1GB。每次运行固定时间是5分钟。
为了真实地模拟生产环境我们在第二种场景中注入后台IO。 这些IO由bash脚本生成该脚本就是不断复制很大的文件。在我们的实验室环境中后台工作模块能够产生每秒150MB的磁盘写入负载差不多可以使服务器配备的镜像硬盘驱动器饱和。这个应用程序和后台IO脚本的源代码在[GitHub](https://github.com/zhenyun/JavaGCworkload)上开源。
我们考虑的主要性能指标是关于应用程序的STW暂停具体考虑了两个指标
1. 总暂停时间即所有STW暂停的总暂停时间
2. 较大的STW暂停计数和。
下面让我们一起来看看结果。
场景A是基准场景Java程序在没有后台IO负载的情况下运行。我们在实验室环境中执行了许多次运行得到的结果基本是一致的。
![](https://static001.geekbang.org/resource/image/9e/c9/9e14b3630deefda85d0b1b4128d15fc9.png)
图片中显示的是一个持续5分钟的运行就是沿着5分钟的时间线显示了所有JVM STW暂停的时间序列数据。我们观察到所有暂停都非常小并且STW暂停都不会超过0.25秒。 STW的总暂停时间约为32.8秒。
场景B是有后台IO负载情况下运行相同的Java程序。在实际的生产过程中IO负载可能来自很多地方比如操作系统、同一机器上的其他应用程序或者来自同一Java应用程序的各种IO活动。
![](https://static001.geekbang.org/resource/image/d3/35/d3a781387eb9ebc20513146a63a4ad35.png)
在图片中我们同样沿着5分钟的时间线显示了所有JVM STW暂停的时间序列数据。我们发现当后台IO运行时相同的Java程序在短短5分钟的运行中看到1个STW暂停超过3.6秒3个暂停超过0.5秒结果是STW的总暂停时间为36.8秒比基准场景多了12
而STW总暂停时间多也就意味着应用程序的实际工作吞吐量比较低因为JVM多花了时间在STW暂停上面。
## 响应延迟的根本原因在哪里?
为了弄清楚STW暂停的原因我们接着进行了深入的分析。
我们发现STW大的暂停是由GC日志记录write()调用被阻塞导致的。这些write()调用虽然以缓冲写入模式即非阻塞IO发出但由于操作系统有“回写”IO的机制所以仍然可能被操作系统的“回写”IO阻塞。
操作系统的“回写”机制是什么呢?就是文件系统定期地把一些被改变了的磁盘文件,从内存页面写回存储系统。
具体来说当缓冲的write()需要写入文件时它首先需要写入OS缓存中的内存页面。这些内存页是有可能被“回写”的OS缓存机制锁定的而且当后台IO流量很重时该机制可能导致这些内存页面被锁定相当长的时间。
为了彻底查清原因我们使用Linux下的Strace工具来剖析函数调用和STW暂停的时间相关性。
![](https://static001.geekbang.org/resource/image/5a/4e/5a307738734b433b0d82723c016e9d4e.png)
这个图表示的是**JVM STW暂停**和strace工具报告的**JVM进行write()系统调用的延迟**。图片集中显示了一个1.59秒的JVM STW暂停的快照。
我们仔细检查了两个时间序列数据发现尽管JVM的GC日志记录使用缓冲写入但是GC暂停和write()延迟之间有极大的相关性。
这些时间序列的相关性表明由于某些原因GC日志记录的缓冲写入仍然被阻塞了。
那这个原因是什么呢我们就要通过仔细阅读分析JVM GC的日志和Strace的输出日志如下图所示来寻找。
![](https://static001.geekbang.org/resource/image/53/f6/53fd4368c2fc468b368f938ca3aaf2f6.png)
让我沿着时间轴线来具体解释一下图片中的数据。
在时间35.04秒时,也就是第 2 行日志一个新的JVM新生代GC启动并用了0.12秒才完成。新生代GC在35.17秒时结束并且JVM尝试发出write()系统调用第4行将新生代GC统计的信息输出到GC日志文件。write()调用被阻塞1.47秒所以最后在时间36.64第5行结束总共耗时1.47秒。当write()调用在36.64返回给JVM时JVM记录此STW暂停为1.59秒即0.12 + 1.47第3行
这些数据表明GC日志记录过程恰好位于JVM的STW暂停路径上而日志记录所花费的时间也是STW暂停的一部分。如果日志记录也就是write()调用被阻塞那么就会导致STW暂停。换句话说实际的STW暂停时间由两部分组成
1. 实际的GC时间例如新生代GC时间。
2. GC日志记录时间例如write()执行时被OS阻塞的时间。
我用下图来清楚地表示它们之间的关系。
![](https://static001.geekbang.org/resource/image/6f/16/6fb8cb8e982ec471bb371fb42d2c9416.png)
左边是JVM的活动右边是操作系统的活动。时间T1时垃圾回收开始T2时GC结束并开始调用write()写日志。这之间的延迟就是GC的延迟。T3时write()调用开始T4时write()调用返回。这之间的延迟就是OS导致的阻塞延迟。所以总的STW暂停就是这两部分延迟的和也就是T4-T1。
那么接下来的一个很难理解的问题是为什么非阻塞IO还会被阻塞
在深入研究各种资源包括操作系统内核源代码我们意识到非阻塞IO写入还是可能会停留并被阻塞在内核代码执行过程中。具体原因有好几个其中包括**页面写入稳定**Stable Page Writing和**文件系统日志提交**Journal Logging。下面分别说明。
JVM写入GC日志文件时首先会改变也就是“弄脏”相应的文件缓存页面。即使以后通过操作系统的写回机制将缓存页面写到磁盘文件中被改变内存中的缓存页面仍然会由于稳定的页面写入而导致**页面竞争**。
根据页面写入稳定的机制如果页面处于OS回写状态则对该页面的write()必须等待回写完成。这是为了避免将部分全新的页面保留到磁盘(会导致数据不一致),来确保磁盘数据的一致性。
对于日志文件系统在文件写入过程中会生成适当的日志。当附加到GC日志文件而需要分配新的文件块时文件系统需要首先将日志数据保存到磁盘。在日志保存期间如果操作系统具有其他IO活动则可能需要等待。如果后台IO活动繁重则等待时间可能会很长。
## 解决方案如何落地?
我们已经看到由于操作系统的各种机制的原因包括页面缓存写回、日志文件系统等JVM可能在GC日志期间被长时间阻塞。
那么怎么解决这个问题呢?
我们思考了三种方案可以缓解这个问题,分别是:
1. 修改JVM
2. 减少后台IO
3. 将GC日志记录与其他IO分开。
修改JVM是将GC日志记录活动与导致STW暂停的关键JVM GC进程分开。这样一来由GC日志阻塞引起的问题就将消失。
比如JVM可以将GC日志放到另一个线程中该线程可以独立处理日志文件的写入也就不会造成另外一个应用程序线程的STW暂停。这个方案的缺点是采用分线程方法可能会在JVM崩溃期间丢失最后的GC日志信息。
第二种方案是减少后台IO。
后台IO引起的STW暂停的程度取决于后台IO的强度。因此可以采用各种方法来降低这些IO的强度。比如在JVM应用程序运行的服务器上不要再部署其他IO密集型应用程序。
第三种方案是将GC日志与其他IO分开。
对延迟敏感的应用程序例如为交互式用户提供服务的在线应用程序通常无法忍受较大的STW暂停。这时可以考虑将GC日志记录到其他地方比如另外一个文件系统或者磁盘上。
比如这个文件系统可以是临时文件系统tmpfs一种基于内存的文件系统。它具有非常低的写入延迟的优势因为它不会引起实际的磁盘写入。但是基于tmpfs的方法存在持久性问题。由于tmpfs没有备份磁盘因此在系统崩溃期间GC日志文件将丢失。
而另一种方法是将GC日志文件放在更快的磁盘上例如SSD。我们知道就写入延迟和IOPS而言SSD具有更好的IO性能。
## 如何证明解决方案是否有效?
得出方案后,我们就需要对它们进行验证了。
在我们提出的三种方案中第一种方案也就是改进JVM是暂时难以实现的因为它需要修改JVM的实现机制。第二种方案呢是不言自明。如果没有背景IO或者有较少背景IO那么自然背景IO的影响就变小了或者减少GC日志输出的频率就不会有那么多次STW暂停了。
因此我们这里直接来验证第三种方案将GC日志记录与其他IO分开。
我们的方法是将GC日志文件放在SSD文件系统上来验证这种方案。我们运行与前面实验场景中相同的Java应用程序和后台IO负载。下图中显示了一个5分钟运行时段的所有STW暂停和相应的时间戳。
![](https://static001.geekbang.org/resource/image/8e/35/8e79fbff8312b961d1d9a65ab3d07335.png)
对于STW暂停的信息我们注意到所有的JVM的暂停都非常小所有暂停都在0.25秒以下。可以说延迟暂停方面的性能得到了很大的提高这表明如果这样分开GC日志文件即使有很大的后台IO负载也不会导致JVM程序发生较大的STW暂停这样的结果也就验证了这一方案的有效性。
## 总结
今天我们探讨了一个跨层的性能分析和优化案例。由于计算机几个层面的技术互相影响实际生产环境中会出现非常大的响应时间延时严重影响公司业务。这些层面包括应用程序、JVM机制、操作系统和存储系统。
![](https://static001.geekbang.org/resource/image/ee/7a/eee73e11928fdfbe864008124aa2957a.png)
我们通过合理的性能测试和性能分析包括搭建合适的测试环境进行问题重现、详细的根因分析最终提出了几种解决方案。简单来说就是JVM在GC时会输出日志文件写入磁盘时会因为背景IO而被阻塞。把日志文件和背景IO分开放在不同磁盘从而让它们互相不影响的话就能大幅度降低STW延迟。
从这个案例我们也可以再次了解,从事性能优化工作需要通晓几乎所有层面的知识。而且不光要知识面广,还要能深入下去,才能进行彻底的根因分析。
唐代诗人刘禹锡有一首诗说:“莫道谗言如浪深,莫言迁客似沙沉。千淘万漉虽辛苦,吹尽狂沙始到金。”对待这样复杂的性能问题,我们也需要不怕困难,不惧浪深沙沉。因为只有经过千淘万漉的辛苦,才能淘到真金,发现问题的本质并彻底解决它。
## 思考题
既然这个问题是Java应用程序偶尔发生大延迟原因是JVM垃圾回收GC的Log日志写到硬盘的文件系统引起的我们如何可以让JVM把GC日志写到内存去吗
> Tips考虑一下JVM参数。
欢迎你在留言区分享自己的思考,与我和其他同学一起讨论,也欢迎你把文章分享给自己的朋友。