服务器 频道

携程酒店查询服务轻量化探索和实践

  一、背景和目标

  在容器化部署成为主流的现在,降低集群中单个容器的资源需求的意义已经不只限于更少的硬件成本,同时也意味着整个集群更加轻量化,这通常会带来一系列其他优势:例如更短的恢复时间,更精确的资源控制和调度,和更快速的伸缩和部署等。但在另一方面,一味的追求压缩容器配置必然会严重影响应用在稳定性、响应耗时和吞吐量等方面的表现,所以轻量化的措施需要在多个性能维度上进行仔细的权衡取舍,以达到一个总体更优的结果。

  作为携程计算量最大的接口之一,酒店查询服务一直承担着沉重的硬件成本压力,仅仅详情页集群就包含了千余台服务器实例和数十TB的Redis资源,因此对应用进行全面的轻量化有着很高的必要性和预期收益。在内存方向上,我们的主要目标是将单个容器的内存从32GB压缩到16GB,并在以下两个基本方向上进行了探索:

  减少内存增长速度:压缩本地缓存,减少浮动内存的产生,并对线程,类库,参数和代码逻辑进行针对性的优化和调整。

  提升内存管理效率:加强JVM等服务依赖的基础组件其本身的性能。

  由于第一个方向需要根据应用的具体代码实现来分析和排查,普适性相对较差,所以本文将主要分享查询服务在轻量化中对于内存管理方向上的探索过程和实践经验。

  二、堆内内存管理

  我们的应用原本运行在JDK8的CMS收集器之上,但是在JDK11以后,CMS已经被完全淘汰。于是,要提高堆内的内存管理效率,我们首先尝试的便是对GC进行升级和调优。因此我们对G1、ZGC和ShenandoahGC等更现代的收集器进行了性能上的测试和对比,来尝试找出最合适的技术选型。

  2.1 垃圾收集器的选型

  首先,在JDK17上,ZGC第一次以生产环境可用的状态登陆了LTS版本,所以我们这次选型起初的目标也是尝试将应用迁移到ZGC之上。相对于大家熟悉的G1,ZGC最主要的优势在于其通过着色指针和读屏障两个特性,使得用户线程几乎可以全程与标记-复制算法并行,基本解决了YGC的STW问题。简单来说,ZGC在标记过程中会向64位指针的高位4bit中记录三色标记、重分配标记和可达性标记;当应用线程访问对象时,读屏障机制会依据指针状态和复制表信息去更新对象的地址和状态。

  这样,即使GC线程正在后台转移、复制或清理对象,也可以保证前台线程能始终访问到正确的地址,这使得ZGC几乎可以做到无停顿回收。除此之外,ZGC还向用户承诺了可扩展性:由于ZGC的停顿时间基本只和初始扫描中GC Roots的数量相关,堆的大小和活跃对象的数量并不会导致停顿时间的增长。  

  其次,ShenandoahGC与ZGC同为新一代的零停顿收集器,总体来看,其内存布局非常类似于G1,而并发设计则与ZGC如出一辙,所以我们也将其作为一个可能的备选方案。

  ShenandoahGC与ZGC的主要区别在于其使用的是Brook指针而非染色指针:即在对象头中额

  外记录一个指向复制后正确地址的指针。但是由于额外信息记录在对象头中,Brook指针的读屏障无法在第一次访问后直接更新正确地址来自我恢复。另一方面,ShenandoahGC的区块布局和回收阶段则与G1非常相似,甚至部分代码都是直接复用的。其不同主要在于ShenandoahGC利用了一个被称为连接矩阵的二维数组来取代G1中开销巨大的记忆集,来解决跨区引用问题:例如区块N引用了区块M,则在数组的`[N][M]`坐标打上标记。

  最后,作为现在最主流的收集器,同时也是CMS的取代者,G1理所应当的也被我们作为最成熟和稳妥的一个选择。G1本身的内存布局使得其对可控的停顿耗时和吞吐量的平衡上有较好的兼顾,在理论上使它更适合查询接口这种会短时间内突然生成大量临时对象的计算密集型应用。

  综上所述,我们以原本在轻量化前的生产配置(16C32G+JDK1.8+CMS)作为基准,选取了以下几个组合作为测试方案:  

  其他各相关参数都为默认配置。

  2.2 G1调优实践

  在横向比较不同收集器的性能之前,我们首先需要按应用的需求对每个收集器做一些简单的适配和调整,以发挥这些收集器的全部性能。由于G1是为开箱即用而准备的默认收集器,使用起来相对简单,基本上只需要简单设置下堆大小和线程数等参数即可。然而在实际使用中,我们仍然遇到了一些小问题,需要对关键某些参数进行控制。

  (1)现象1:某个高压力场景的计算集群的YGC频率相对较高,GC吞吐量不足,最终甚至会引发FGC。

  由于轻量化配置的资源本身就比较紧张,很难通过增加`ConcGCThreads`线程数来提升吞吐量,于是我们尝试放宽了`MaxGCPauseMillis`来减少G1的回收压力。作为G1最核心的参数,当`MaxGCPauseMillis`过小时,G1会自动调整其他GC参数来尽可能满足该目标,进而导致YGC非常频繁并影响吞吐量。由于各个应用的情况不同,需要开发人员手动进行基准测试来找到最合适的数值:当我们把数值从200调整到300后,平均响应有非常明显的下降,而继续上调的边际效应则很不明显。

  (2)现象2:应用在长时间稳定运行后,老年代突然大量上涨后却不及时回收,在数小时内老年代都维持高位,挤占年轻代的占比并影响到了YGC的停顿耗时。

  由于查询服务主要是无状态的计算逻辑,除了一部分本地缓存外大部分的对象都是相对短命的,但是当GC压力上升的情况下仍然会有大量对象进入老年代。这里我们通过缩小`InitiatingHeapOccupancyPercent`来降低MGC的阈值,让G1可以及时回收掉进入老年代的短命对象。

  除此之外,我们也对JDK8、11和17上的G1进行过纵向对比,发现实际反映到机器性能和响应耗时维度上的区别非常小,在排除掉宿主机本身的硬件区别后几乎可以忽略不计。最终,由于依赖类库和监控平台等各种原因,最终选择了相对成熟的JDK11作为G1的平台。

  2.3 ZGC调优实践

  与G1的普适明显不同,对ZGC上的适配工作明显困难的多,作为最现代的收集器之一,ZGC并不是万用的银弹,因此也并没有成为JDK17的默认收集器。相比G1,ZGC并不能简单的适配于所有场景,我们在试运行过程中遇到了一系列难以解决的问题,经过大量的参数调整和性能测试后,才能发挥其全部的实力。

  (1)现象1:在访问量突然上涨时,会观察到非常显著的分钟级响应尖刺。

  由于ZGC会使用之前数次的GC指标来预测下一次GC的回收策略,使得其相比CMS等传统收集器更容易受到流量波动的冲击,在高流量压力下的鲁棒性很差。解决响应尖刺的办法主要有两个:首先是通过提高`ZAllocationSpikeTolerance`的数值来减少触发GC的阈值,其次是打开周期性的主动回收参数`ZProactive`,并通过减少`ZCollectionInterval`来缩短两次主动GC的间隔。两个参数的具体数值需要开发人员手动调试确定,但是在高压力下无论怎么调整参数,提升都相对有限。  

  (2)现象2:GC日志出现大量Allocation Stall/Rellocation Stall,同时监控上发现秒级的STW出现,某些时候甚至伴有OOM报错。

  虽然ZGC的停顿时间很短,但整个回收阶段很长,期间用户线程一直处于并发运行的状态。这使得回收过程中会产生大量的浮动垃圾,只能等到下次GC时再回收。此时如果浮动垃圾占满了整个堆使得回收无法继续,ZGC就会直接暂停对应的用户线程,来优先执行回收任务,同时在日志上记录对应线程的Allocation/Relocation Stall。简而言之,Allocation Stall是一种当GC吞吐量不够时触发的用户线程暂停,大量秒级的Allocation Stall甚至比FGC的影响更大。

  这种情况一般都是GC回收速度跟不上内存申请速度导致的,如果GC资源相对充足的话,可以通过上面两个主动GC参数来增加GC频率,而如果GC资源本身就很匮乏,则只能通过增加GC线程数`ConcGCThreads`和`ParallelGCThreads`来根本性的解决问题。  

  (3)现象3:ZGC堆使用的RSS持续上升,其大小不会随着内存使用情况智能伸缩,最终导致了堆外溢出。

  低版本的ZGC并不会主动将长期未使用的堆内存返还给系统,JDK13后ZGC提供了`ZUncommitDelay`参数来设置将空闲内存返还给OS的期限,可以通过缩短这个值来使得RSS空间被更加灵活的使用。为了保证生产环境服务的稳定性,我们直接通过让堆大小的上下限相同来防止堆的伸缩。

  2.4 基准测试的结果

  在实际试验中,我们首先观察到ZGC确实无愧于其零停顿收集器的名号,可以做到在全程任何情况下都达到百微秒级每次的停顿时间,每分钟累计不超过1ms,同时CMS中令人困扰的FGC现象也不再成为问题。而与之原理相近的ShenandoahGC的性能表现也非常好,平均每分钟的停顿时间也不超过10ms。G1与这些新一代的收集器相比虽然逊色许多,但是仍然能在仅仅使用生产配置一半的内存下,达到比CMS更好的GC性能:其YGC停顿约下降了50%,每分钟停顿约为200ms左右,并且MGC也基本可以满足老年代的回收需要,数天时间内没有观察到FGC。

  但是随着流量压力的上升,我们很快发现作为首选的ZGC和ShenandoahGC等零停顿收集器实际上并不适合查询接口这样的运算密集型应用:他们的运行显著地依赖于资源开销,最终严重挤占了业务逻辑的计算资源,使得响应耗时飙升,服务趋于崩溃。

  为了稳定服务,我们不得不重新分配了更多计算资源并降低了流量,并得出了结论:即使使用了两倍的线程资源,在CPU利用率三倍于CMS的情况下,ZGC和ShenandoahGC仍然只能达到相当于生产环境约50%-60%的极限吞吐量。与之相对,G1在这方面的表现则好得多,在轻量化配置下比起生产配置的整体吞吐量仅稍有下降,在相同QPS下的CPU利用率变高了约5-7%,几乎可以忽略不计。经过后续排查,我们发现主要的原因是ZGC的四条ZWORKER线程几乎每个都会100%的占用一个核心(如下图),大量的吞吃了CPU资源并影响到了核心处理流程。  

  在内存方面,ZGC的实际表现也并不尽如人意。ZGC不仅比传统收集器记录了更多的额外信息,且很多优化和特性(例如字符串去重、分代、指针压缩等)也暂时无法使用,这使得ZGC无论是堆内还是堆外的内存开销都要明显高于G1和CMS。测试中我们使用NMT和JMX简单对比了各收集器在未接入流量一段时间内的平均内存开销,发现ZGC的堆内开销大约比CMS高三分之一,堆外则高达CMS的10倍左右。

  综合来看,ZGC等零停顿收集器虽然可以达到10ms以下的每分钟停顿时间,但是其占用的大量GC资源会严重影响核心计算逻辑。在资源紧张的轻量化场景下,切换至ZGC导致了服务在极限压测中损失了相当于100%生产流量的吞吐量,同时接口的响应耗时上升了70%。与之相对,G1在响应耗时上的表现则几乎与原来相同,各细节指标上也仅仅只有CPU利用率略差。  

  2.5 迁移到JDK11

  由于ZGC和ShenandoahGC在测试中表现不佳,且各种问题在资源紧张的限制下几乎无法解决,我们最终将目光转向了更为成熟稳定的G1+JDK11的组合。从JDK8迁移到11是两个连续的LTS版本之间的迁移,比起直接迁移到17来说简单很多。我们在实际迁移中主要遇到了三个类型的问题:官方类库缺失,权限控制,以及第三方类库报错。

  官方类库缺失:JDK11中移除了一系列官方类库,其中部分类库只是从rt.jar中被拆分,可以简单的通过maven补回,例如javax.*,但是其他一些包含危险操作的类库则被直接删除,例如jdk.nashorn,sun.misc等,一般也可以通过重写来绕过这些代码。

  权限控制:JDK11中对各种权限做了更精细的控制。例如自代理需要使用参数`-Djdk.attach.allowAttachSelf`控制,而跨类库的反射权限则需要用`--add-exports=`和`--add-exports`来打开。如果未能注意到这些参数则会造成大量权限控制报错。

  第三方类库报错:此类报错一般都是兼容性问题导致,Lombok和AspectJ等类库需要根据JDK版本来选择对应的类库版本。主要排查难点在于报错的形式五花八门,报错信息对定位几乎没有帮助,有时候很难确定是哪个类库导致的。

  三、堆外内存管理

  一般来说,对于运行在容器中的单个Java应用,大部分堆外相关的细节都会被虚拟机给屏蔽掉,导致开发人员往往很少会深入到相关问题。然而,由于这次轻量化后剩余的内存资源非常紧张,我们被迫给堆外留下了非常有限的空间,导致了后续测试过程中出现的一系列问题。

  3.1 问题表象与排查

  具体来说,在生产测试中,我们经常观察到应用经常在半夜多次无故宕机后被拉起,最终反复点火失败导致应用崩溃无法继续服务。经过大量试验后,我们发现这一现象与JDK版本和GC无关,可以在多个轻量化配置上复现,现象为RSS在较长的一段时间内持续上升,最终导致了应用多次崩溃重启。  

  由于是RSS持续上升,我们排查时首先怀疑是堆内溢出。但是却并没有在应用日志上发现OOM报错,且使用JFR检查堆内存增长情况后也并没有找到明显的溢出迹象。所以我们进一步检查了机器的dmesg日志,发现反复崩溃的原因是malloc申请不到内存,导致内核的oom_killer线程直接kill掉了DMESGTomcact进程,然后又被重启脚本重新拉起。  

  由于堆内存是基本稳定的,我们使用NMT baseline对JVM的堆外内存使用情况进行了检查。虽然现代GC本身的native占用相对较高,但增长并不显著,总体内存使用很稳定,也没有泄露的倾向。  

  进一步向下排查,我们用 `gdb --batch --pid 36563 --ex 'call malloc_trim()'` 强行回收内存后,发现RSS有明显下降,至此基本判断是glibc这一块造成了泄露问题。  

  3.2 内存分配器

  要解释溢出的原因,首先需要了解一下内存管理的机制。对于Java应用来说,内存管理一般分为四层:内核负责管理和映射虚拟页,glibc进行通用的内存算法管理,JVM负责屏蔽内存申请和回收的细节,而最上层才是Java应用。  

  由于mmap和brk是系统调用,如果应用每次申请内存时都直接访问内核函数的话,性能会非常差,代码实现也更加困难。所以,linux会使用通用的内存分配算法来缓冲和规划内存的使用,其主要关注点在三个指标上:

  减少申请和释放操作的时间和开销

  减少小对象分配带来的内存碎片

  减少分配器本身数据结构的额外内存开销

  3.3 默认分配器PTMALLOC的优缺点

  在默认情况下,glibc使用的是其原生的ptmalloc2内存分配器,由以下三层数据结构组成:

  arena(分配区)是ptmalloc中的内存缓冲区,在一个环形链表上被管理。也是ptmalloc中最小的锁颗粒度。

  bin(空闲链表)是arena中用于管理可用内存块的链表。不同的bins根据其管理的内存块大小而被分为fast、unsorted、small和large四种。

  chunk(内存块)则是用户申请和释放内存的最小单位。bins的头部永远是一个被称为top chunk的空闲块,当没有合适的chunk时,会扩容并返回top chunk来处理请求。  

  ptmalloc作为标准实现,主要使用了以下几个方法来优化以上三个重要指标:

  内存池(减少频繁的系统调用和内存碎片):用户free掉的内存,不会被直接被归还给系统,而是暂存到bins中,供下次申请时直接分配。

  多分配区(减少锁竞争):所有内存操作都需要加锁,如果没有找到未上锁的arena,则会新增一个副arena并上锁,直到arena的数量上限。

  ptmalloc虽然满足了内存分配器的基本需求,但是本身实现有很多缺陷,导致了内在的OOM倾向:

  额外内存开销大:每个chunk都需要额外消耗8b的内存,而chunk是内存操作的最小单位,这会导致整体上浪费了非常多内存。

  内存利用率不稳定:由于多分配区的机制,激烈的锁竞争会导致副arena数量快速增多。并且,新增的副arena永远不会被销毁,且保留会其初始的chunk。这意味着在一台16核的标准机器上最多会有128个arena,并占用高达8G的堆外缓冲区。

  多线程性能差:所有的内存操作都需要进行悲观锁的加锁解锁操作,导致其性能较差。同时,即使有多分配区机制,在动辄500个以上线程的生产环境中这个并发量完全不够。

  回收机制简陋:由于bins是链表结构,ptmalloc的内存收缩必须从上向下收缩,这意味着只要后申请的内存没有被释放,之前申请的所有chunk都无法被收缩,这导致了在管理长周期内存时,有内存泄漏的可能性。

  具体到这次详情页的溢出,则主要有三个原因:

  前置条件:由于轻量化的需要,应用目前仅仅能给堆外大约2.5G的空间。并且G1本身使用的堆外空间是CMS的4-6倍之多。而原来的32G的堆外空间充足,所以之前没有发现类似问题。

  由于大量的缓存、快照和报文的处理,应用本身有非常频繁和重量级的NIO和序列化/压缩操作(尤其是点火的时候,线程数非常多),这导致了应用会高频的申请和释放堆外内存作为IO缓冲区。因此ptmalloc在这种情况下新增了大量的arena来避免频繁的锁竞争(下图中有大量64M大小的内存块)。

  ptmalloc本身的释放机制就导致申请的内存被归还的特别慢,甚至有内存溢出的倾向,这些因素综合在一起引起了OOM的发生。  

  3.4 解决方案JEMALLOC

  考虑到ptmalloc的性能相对较差,我们将目光转向了第三方的内存管理器。无论是谷歌的tcmalloc还是脸书的jemalloc都完全是默认分配器的上位替代,各项性能远超ptmalloc,并且迁移起来非常方便。虽然tcmalloc和jemalloc两者之间优劣差别不大,但是由于jemalloc相对优秀的工具链,我们最终优先对它进行了测试。

  jemalloc是专精于多核多线程场景的内存分配器,可以说在并发量越大的情况下,jemalloc的优势越明显。对比pemalloc,jemalloc有以下的优势:

  (1)内存碎片率:jemalloc承诺至多20%的内存碎片。

  通过将内存块根据大小进一步细分为232个小类,同一个bins中的内存块大小一致,向上取整申请,来提高每个内存块在分配时的利用率和性能。(同样处理10kb内存的申请,返回10kb的 chunk和返回20kb的chunk之间肯定有区别)

  采用了低地址优先的分配策略,进一步降低了内存碎片率。(使用红黑树记录了地址排序,总是从低地址开始分配,使高地址的内存更整块)

  (2)锁的颗粒度:jemalloc在大部分场景下几乎是无锁的。

  每个线程都拥有动态伸缩的缓存tcache,在小内存操作时是无锁的。

  大部分的线程都会被绑定到专属的arena上,使其操作无锁化(类似于JVM的偏向锁)。即使多个 线程共享一个arena,也会在arena内部细化为局部锁,而不是直接使用全局锁。

  (3)内存回收:除了类似于ptmalloc的回收机制外,jemalloc还有两种机制。

  当发现某个chunk全部都是脏页后,会直接释放整个chunk。

  当脏页数量超过某个阈值的时候,进行主动的purge操作。

  (4)额外开销:仅仅占用约2%的额外内存,用于存储一些meta信息。

  (5)工具链:jemalloc有完善的内存分析工具,可以更好的定位溢出和泄露问题。  


  3.5 迁移和收益

  对于简单的性能测试,手动安装jemalloc非常容易,甚至不需要重新编译代码,直接在一台正常运行的机器上安装好jemalloc后,修改tomcat的sh文件中将LD_PRELOAD变量指定为对应的so文件覆盖glibc动态库并重启tomcat即可。而后续容器部署也只需要在dockerfile中自定义数行代码模拟上述操作,然后构建并上传自定义镜像便能完成。

  目前查询服务已经在jemalloc上生产运行了数个月,至今还没有观察到再次出现堆外溢出的问题;同时RSS的波动非常稳定,即使遇到流量高峰也不会出现内存尖刺,可以保持良好的响应时间和稳定。

  从实际的情况来看,jemalloc与ptmalloc相比主要有以下收益:

  从运维方面来看,集群为了方便调度,一般会限制几个预设的容器配置以供选择。在资源相对紧张的情况下,jemalloc可以使得应用整体的部署更加灵活,而使用默认的ptmalloc则会被迫将容器配置向上升级,否则就需要额外对特殊配置进行审批和调度,这样不但会造成不必要的资源浪费,同时在流量尖峰时也难以对集群进行调度和扩容。

  在成本方面,从测试结果出发,仅仅使用jemalloc本身就能比ptmalloc在每台机器上节省1-1.5G的堆外内存,虽然在单机上可能不够显著,但是推广到整个云的范围时收益应该是非常可观的。

  性能上,jemalloc的内存回收和多线程机制更加高效和智能化,对低配置机器更加友好,能大大加强内存资源紧张的机器上服务的鲁班性,同时对IO、GC、类加载等多线程native操作有较大的优化。

  从迁移角度看,迁移到jemalloc几乎是无成本的操作,仅仅需要简单的镜像自定义和一定的灰度测试,就可以完成优化。

  故综合来看,jemalloc的收益相比于成本大得多的,有一定的分享和推广的意义。

  四、结语

  本文相对完整的记述了酒店查询服务在轻量化中的一次优化过程,希望其中的经验和过程能对读者有所帮助。然而,对于应用的优化过程是一个从猜想到验证的循环。在有了可能的猜测和方向之后,比起反复的调研,更重要的则是不断向着落地验证去推进。虽然这些经验有一些普适性,但是由于应用之间各有不同,仍然需要读者根据实际情况亲手试验后,才能最终确定是否有借鉴意义。

0
相关文章