作为站点可靠性工程师 (SRE),我确保我们的生产服务高效,可扩展且可靠。 典型的SRE是生产大师,必须对更广泛的体系结构有很好的了解,并精通许多更精细的细节。
SRE是会说多种语言的程序员,这是很常见的,他们希望能够理解多种不同的语言。 例如,C ++可能很难编写,测试和正确使用,但具有高性能,非常适合诸如数据库之类的后端系统。 Python很容易编写,非常适合快速编写脚本,对自动化很有用。 Java位于中间位置,尽管它是一种编译语言,但它提供了类型安全性,性能以及许多其他优点,使其成为编写Web基础结构的理想选择。
尽管SRE采用的许多最佳实践可以推广到任何语言,但是Java还是存在一些独特的挑战。 本文计划重点介绍其中一些,并讨论我们可以采取哪些措施来解决这些问题。
部署方式
一个典型的Java应用程序由数百个类文件组成,这些类文件由您的团队编写,或者由应用程序依赖的通用库编写。 为了控制类文件的数量,并提供更好的版本控制和分隔,通常将它们捆绑到JAR或WAR文件中。
托管Java应用程序的方法有很多,一种流行的方法是使用Java Servlet容器,例如Tomcat或JBoss 。 从理论上讲,它们提供了一些通用的Web基础结构和库,以使其更易于部署和管理Java应用程序。 以Tomcat(一个提供实际Web服务器并代表您加载应用程序的Java程序)为例。 这在某些情况下可能会很好地起作用,但实际上会增加额外的复杂性。 例如,您现在需要跟踪JRE版本,Tomcat版本和应用程序版本。 测试不兼容性,并确保每个人都使用相同版本的完整堆栈可能会出现问题,并导致细微的问题。 Tomcat还带来了自己的定制配置,这是另一回事。
一个很好的承租人是“ 保持简单 ”,但是在Servlet容器方法中,您必须跟踪几十个Tomcat文件,一个或多个组成应用程序的WAR文件以及所有的Tomcat配置。随之而来。
因此,有些框架通过嵌入自己的Web服务器,而不是托管在完整的应用程序服务器中,试图减少这种开销。 仍然有一个JVM,但它会调用一个JAR文件,其中包含运行该应用程序所需的所有内容。 支持这些独立应用程序的流行框架是Dropwizard和Spring Boot 。 要部署该应用程序的新版本,只需更改一个文件,然后重新启动JVM。 这在开发和测试应用程序时也很有用,因为每个人都在使用相同版本的堆栈。 对于回滚(SRE的核心工具之一)来说,它也特别有用,因为只需更改一个文件即可(可以快速更改符号链接)。
Tomcat样式的WAR文件要注意的一件事是,该文件将包含应用程序类文件以及该应用程序依赖于JAR文件的所有库。 在独立方法中,所有依赖项都合并到单个Fat JAR中 。 单个JAR文件,其中包含整个应用程序的类文件。 这些Fat或Uber JAR不仅易于版本化和复制(因为它是单个不可变文件),而且由于对依赖项中未使用的类进行修剪而实际上可以小于等效的WAR文件。
通过不需要单独的JVM和JAR文件,甚至可以更进一步。 实际上,例如capsule.io之类的工具可以将JAR文件,JVM和所有配置捆绑到一个可执行文件中。 现在,我们可以真正确保整个堆栈都使用相同的版本,并且部署与服务器上可能已经安装的内容无关。
保持简单,并使用单个Fat JAR或可能的可执行文件,使应用程序的版本快速便捷。
启动
即使Java是一种编译语言,它也不会编译为机器代码,而是会编译为字节码。 在运行时,Java虚拟机(JVM)解释字节码,并以最有效的方式执行它。 例如, 即时 (JIT)编译使JVM可以监视应用程序的使用方式,并即时将字节码编译为最佳机器代码。 从长远来看,这对应用程序可能是有利的,但是在启动过程中,它可能会使应用程序在数十分钟或更长时间内处于次优状态。 需要注意的是,这对负载平衡,监视,容量规划等有影响。
在多服务器部署中,最佳实践是将流量缓慢增加到新启动的任务,使其有时间进行预热,并且不损害服务的整体性能。 在将新任务放入用户服务路径之前,您可能会通过向其发送人工流量来预热新任务。 如果预热过程无法逼近正常的用户流量,则人工流量可能会出现问题。 实际上,这种伪造的流量可能会触发JIT对通常不会发生的情况进行优化,从而使应用程序处于未达到最佳状态或处于比未进行JIT时更糟糕的状态。
容量规划时也应考虑启动缓慢。 不要期望冷任务与热任务处理相同的负载。 推出新版本的应用程序时,这一点很重要,因为系统的容量会下降,直到任务预热。 如果不考虑这一点,可能会同时重新加载太多任务,从而导致基于容量的级联中断。
期待冷启动,并尝试以实际流量预热应用程序。
监控方式
该建议是通用的监视建议 ,但是对于Java值得重复。 确保从Java应用程序导出最重要和最有用的度量标准,并对其进行收集并轻松绘制图形。 有许多工具和框架可用于导出指标,甚至还有更多的工具和框架可用于收集,汇总和显示。
当出现问题时,应该仅从收集的指标中排除故障即可 。 您不应该依赖日志文件或查看代码来处理中断。
大多数中断是由更改引起的。 也就是说,应用程序的新版本,配置更改,新的流量来源,硬件故障或后端依赖关系的行为不同。 应用程序导出的度量标准应包括识别Java版本,应用程序和使用中的配置的方法。 它应该分解流量,混合,错误计数等的来源。还应该跟踪后端依赖项的运行状况,延迟,错误率等。 在大多数情况下,这足以快速诊断出故障。
特定于Java的指标可以帮助您了解应用程序的运行状况和性能。 指导有关如何扩展和优化应用程序的未来决策。 垃圾回收时间,堆大小,线程数,JIT时间都很重要,并且特定于Java。
最后,关于测量响应时间或等待时间的注释。 也就是说,应用程序处理请求所花费的时间。 许多人会误以为平均等待时间,部分原因是它很容易计算。 平均值可能会引起误解 ,因为它没有显示分布的形状 。 大多数请求可能会很快得到处理,但请求的尾部可能很少,但需要一段时间。 这对于JVM应用程序尤其令人不安,因为在垃圾回收过程中,有一个“ 停止世界” (STW)阶段,在此阶段,应用程序必须暂停以允许垃圾回收完成。 在此暂停中,不会响应任何请求,并且用户可能要等待几秒钟。
最好收集最大或99%(或更高)的百分比延迟。 对于百分位数,也就是说,每100个请求中,有99个的送达速度快于该数字。 查看最坏情况下的延迟更有意义,并且更能反映用户感知的性能。
衡量重要的指标,以后可以依靠。
内存管理
您花费大量时间来学习各种JVM垃圾收集算法 。 当前的最新状态是并发收集器G1或CMS 。 您可以决定哪种方法最适合您的应用程序,但目前G1可能是赢家。 有很多很棒的文章解释了它们的工作方式,但我将介绍一些关键主题。
启动时,Java虚拟机(JVM)通常会保留大量OS内存,并将其分为堆和非堆。 非堆包含诸如Metaspace ( 正式称为Permgen )和堆栈空间之类的区域。 元空间用于类定义,而堆栈空间用于每个线程的堆栈。 堆用于创建的对象,这些对象通常占用大部分内存使用量。 与典型的可执行文件不同,JVM具有-Xms
和-Xmx
标志 ,用于控制堆的最小和最大大小。 这些限制限制了JVM将使用的最大RAM量,这可以使服务器上的内存需求可预测。 通常将这两个标志设置为相同的值,以提供它们以填充服务器上的可用RAM。 还有一些针对Docker容器调整大小的最佳实践。
垃圾回收(GC)是通过查找不再使用(即不再引用)且可以回收的Java对象来管理此堆的过程。 在大多数情况下,JVM会扫描对象的完整图,并标记找到的对象。 最后,将所有未访问的内容删除。 为了确保没有竞争状况,GC通常必须停止运行(STW),这会在应用程序完成时将其暂停一会儿。
GC是(可能是不必要的)怨恨的根源,因为它被归咎于许多性能问题。 通常,这归结为不了解GC的工作原理。 例如,如果堆的大小太小,则JVM可以主动进行垃圾收集,从而徒劳地释放空间。 然后,应用程序可能会陷入“ GC崩溃 ”循环中,这在释放空间方面几乎没有进展,并且在GC中花费了越来越多的时间,而不是运行应用程序代码。
可能发生这种情况的两种常见情况是内存泄漏或资源耗尽 。 垃圾收集的语言不应允许通常所说的内存泄漏,但是,它们可能会发生。 例如,维护一个永不过期的对象的缓存。 此缓存将永远增长,即使该缓存中的对象可能永远不会再使用,它们仍然被引用,因此不适合进行垃圾回收。
另一个常见的情况是无限队列 。 如果您的应用程序将传入的请求放置在无限制的队列中,则该队列可能永远增长。 如果请求数量激增,保留在队列中的对象可能会增加堆使用率,从而导致应用程序在GC中花费越来越多的时间。 因此,应用程序将有更少的时间来处理来自队列的请求,从而导致积压量增加。 随着GC努力寻找任何要释放的对象,这逐渐失去控制,直到应用程序无法取得任何进展。
另外一个细节是,垃圾收集器算法具有许多优化措施,可以尝试减少总的GC时间。 一个重要的发现( 弱代假设 )是对象要么存在很短时间(例如,与处理请求有关),要么存在很长时间(例如,管理寿命长的资源的全局对象)。
因此,堆进一步划分为年轻空间和旧空间。 在年轻空间中运行的GC算法假定对象将被释放,否则,GC会将对象提升到旧空间。 用于旧空间的算法做出了相反的假设,即对象不会被释放。 因此,也可以调整年轻人/老年人的大小,并且根据G1或CMS,方法将有所不同。 但是,如果年轻空间太小,那么应该只存在很短时间的物体最终将被提升到旧空间。 打破了旧的GC算法所做的某些假设,导致GC的运行效率降低,并导致了诸如内存碎片之类的次要问题。
如前所述,GC是长尾延迟的来源,因此应关闭监视。 应该记录GC每个阶段所花费的时间,以及GC运行之前和之后堆空间的满度(按年轻/旧/等等分解)。 这提供了调优或改进应用程序以控制GC所需的所有提示。
让GC成为您的朋友。 应该特别注意堆和垃圾收集器,并且应该对其进行调整(甚至是粗略地调整),以确保即使在满载/最坏的情况下也有足够的堆空间。
其他技巧
调试
Java有许多丰富的工具可用于在开发和生产过程中进行调试。 例如,可以捕获正在运行的应用程序中的实时堆栈跟踪和堆转储。 这对于了解内存泄漏或死锁很有用。 但是,通常必须确保应用程序已启动以允许这些功能,并且典型工具jmap , jcmd等在服务器上实际可用。 在Docker容器或非标准环境中运行应用程序可能会使此操作更加困难,因此请测试并编写一本有关如何执行此操作的手册。
许多框架还通过Web服务公开了许多此类信息,以便于调试,例如Dropwizard / threads资源或Spring Boot生产端点 。
不要等到出现生产问题后,立即测试如何获取堆转储和堆栈跟踪。
更少但更大的任务
JVM的许多功能对每个运行的JVM都有固定的成本,例如JIT和垃圾回收。 您的应用程序也可能具有固定的开销,例如资源轮询(后端数据库连接)等。如果运行较少但较大的实例(就CPU和RAM而言),则可以减少此固定成本,从而实现规模经济。 我已经看到Java应用程序拥有的CPU和RAM数量增加了一倍,使它每秒能够处理4倍的请求(不影响延迟)。 但是,这对应用程序以多线程方式进行扩展的能力做出了一些假设,但是通常垂直扩展比水平扩展容易。
使您的JVM尽可能大。
32位与64位Java
如果您的应用程序使用的RAM不超过4GiB,则通常会运行32位JVM。 这是因为32位指针的大小是64位大小的一半,这减少了每个Java对象的开销。 但是,由于现代CPU是64位的,通常具有64位特定的性能改进,并且RAM的价格便宜,这使得64位JVM无疑是赢家。
使用64位JVM。
减载
还是一般性建议,但对Java很重要。 为避免 GC崩溃或任务繁重而导致过载 ,应用程序应积极减少负载。 也就是说,超出某个阈值,应用程序应拒绝新请求。 尽早拒绝某些请求似乎很糟糕,但是比允许应用程序变得不可恢复并导致所有请求失败都更好。 有许多避免过载的方法,但是常见的方法是确保队列有界,并且线程池的大小正确 。 此外,出站请求应该有适当的截止日期 ,以确保缓慢的后端不会对您的应用程序造成问题。
尽可能多地处理请求,而不再处理。
结论
希望本文使您考虑一下Java生产环境。 尽管不是说明性的,但我们重点介绍了一些重点领域。 整个链接都应指导您正确的方向。
如果您有任何疑问或意见,请通过@TheBramp与我联系,或访问我的网站和博客bramp.net了解更多文章。
翻译自: https://www.javacodegeeks.com/2017/12/running-java-production-sres-perspective.html