蚂蚁消息中间件 (MsgBroker) 在 YGC 优化上的探索

6,371 阅读19分钟
原创声明:本文系作者原创,谢绝个人、媒体、公众号或网站未经授权转载,违者追究其法律责任。

导读


GC 一直是 Java 应用中被讨论得最多的话题之一,尤其对于消息中间件这样的基础应用,GC 停顿产生的延迟会严重影响其在线服务能力,是开发和运维人员关注的重点。

关于 GC 优化,首先最容易想到的就是调整那些影响 GC 性能的 JVM 参数(如新生代与老年代的大小、晋升到老年代的年龄、甚至是 GC 回收器类型等),使得老年代中存活的对象数量尽可能的少,从而降低 GC 停顿时间。然而,除了少数较为通用的参数设置方法可以参照和遵循,在大部分场景下,由于不同应用所创建对象的大小与生命周期不尽相同,GC 参数调优实际上是个非常复杂且极具个性化的工作,并不存在万能的调优策略可以满足所有的场景。同时,由于虚拟机内部已经做了很多优化来尽量降低 GC 的停顿时间,GC 参数调优并不一定能达到预期的效果,甚至很可能适得其反。

抛开被老生常谈的 GC 参数调优,本文将通过讲述蚂蚁消息中间件(MsgBroker) 的 YGCT 从 120ms 优化到 30ms 的历程,并从中总结出较为通用的 YGC 优化策略。

背景


谈到 GC,很多人的第一反应是 JVM 长时间停顿或 FGC 导致服务长时间不可用,但对于 MsgBroker 这样的基础消息服务而言,对 GC 停顿会更加敏感,需要解决的 GC 问题也更加复杂:

  1. 对于普通应用,如果 YGC 耗时在 100ms 以内,一般是无需进行优化的。但对于 MsgBroker 这类在线基础服务,GC 停顿产生的延迟根据业务的复杂程度会被放大数倍甚至数十倍,过高的 YGC 耗时会严重损害业务的实时性和用户体验,因此需要被严格控制在 50ms 以内,且越低越好。然而,随着新特性的开发和消息量的增长,我们发现 MsgBroker 的 YGC 平均耗时已缓慢增长至 50ms~60ms,甚至部分机房的 YGC 平均耗时已高达 120ms。
  2. 一方面,为保证消息数据的高可靠,MsgBroker 使用 DB 进行消息的持久化,并使用消息缓存降低消息投递时对 DB 的读压力;另一方面,作为一个主要服务于在线业务的消息系统,为严格保证消息的实时性,MsgBroker 使用推模型进行消息投递。然而,我们发现,当订阅端的能力与发送端不匹配时,会产生大量的投递超时,并进一步加重MsgBroker 的内存和 GC 压力。订阅端的消费能力会对 MsgBroker 的服务质量造成影响,这在绝大部分场景下是难以接受的。
  3. 在某些极端场景下(例如订阅端容量出现问题,大量消息持续投递超时,随着积压的消息越来越多,甚至可能引发下游链路“雪崩”,导致长时间无法恢复),YGC 耗时非常高,同时也有可能发生 FULL GC,而触发原因主要为 promotion failed 以及 concurrent mode failure,怀疑是因为内存碎片过多所致。

需要指出的是,MsgBroker 运行在普通的 4C8G 机器上,堆大小为 4G,因此使用的是 ParNew 与 CMS 垃圾回收器。

JVM 基础


为了更好地理解后面提及的 YGC 优化思路和策略,需要先回顾一下与 GC 相关的基础知识。

GC 分代假设

对传统的、基本的 GC 实现来说,由于它们在 GC 的整个工作过程中都要 “stop-the-world”,如何缩短 GC 的工作时长是一件非常重要的事情。为了降低单次回收的时间,目前绝大部分的 GC 算法,都是将堆内存进行分代 (Generation) 处理,将不同年龄的对象置于不同的内存空间,并针对不同空间中对象的特性使用更有效率的回收算法分别进行回收,而这主要是基于如下的分代假设:

  • 绝大部分对象的生命周期都非常短暂
  • 剩下的对象,则很可能会存活很长时间,并不太可能使用到年轻对象

基于这个假设,JVM 将内存分为年轻代和老年代,让新创建的对象都从年轻代中分配,通过频繁对年轻代进行回收,绝大部分垃圾都能在 YGC 中被回收,只剩下极少部分的对象需要晋升到老年代中。由于整个年轻代通常比较小,只占整个堆内存的 1/3 ~ 1/2,并且处于其内对象的存活率很低,非常适合使用拷贝算法来进行回收,能有效降低 YGC 时的停顿时间,降低对应用的影响。

然而,如果应用中的对象不满足上述提到的分代假设,例如出现了大量生命周期中等的对象,则会严重影响 YGC 的效率。

YGC 的基本过程

基于标记-复制算法的 YGC 大致分为如下几个步骤:

  1. 从 GC Roots 开始查找并标注存活的对象
  2. 将 eden 区和 from 区存活的对象拷贝到 to 区
  3. 清理 eden 区和 from 区

YGC 耗时分析

当使用 G1 收集器时,通过 -XX:+PrintGCDetails 参数可以生成最为详细的 GC 日志,通过该详细日志,可以查看到 GC 各个阶段的耗时,为 GC 优化提供便利。然而如果使用的是 ParNew 和 CMS 垃圾回收器,实际上官方并未提供可以查看 GC 各阶段耗时的方法。所幸在 AliJDK 中,提供了类似的功能,通过 PrintGCRootsTraceTime 能打印出 ParNew 和 CMS 的详细耗时。MsgBroker 的 GC 详情日志如下:




从上述详细日志中可以看出,YGC 主要存在如下各个阶段:

  • 各种 Roots 阶段:从各类型 root 对象出发标记存活对象
  • older-gen scanning:扫描老年代到新生代的引用以及拷贝 eden 区和 from 区中的存活对象至 to 区
  • other:将需要晋升的对象从新生代拷贝到老年代

通常情况下,older-gen scanning 阶段会在YGC中占用大部分耗时。从上述 GC 详细日志中也能看出,MsgBroker 的 YGC 耗时大约在 90ms,而 older-gen scanning 阶段就占用了约 80ms。

old-gen scanning 阶段

为了有针对性地对 old-gen scanning 阶段耗时进行优化,有必要先了解一下为什么会有 old-gen scanning 阶段。

在常见的垃圾回收算法中,无论是拷贝算法,还是标记-清除算法,又或者是标记-整理算法,都需要从一系列的 Roots 节点出发,根据引用关系遍历和标记所有存活的对象。

对于 YGC,在从 GC Roots 开始遍历并标记所有的存活对象时,会放弃追踪处于老年代的对象,由于需要遍历的对象数目减少,能显著提升 GC 的效率。 但这会产生一个问题:如果某个年轻代对象并不能通过 GC Roots 遍历到,而某个老年代对象却引用了该年轻代的对象,那么该如何正确标记到该对象?

为解决这个问题,一个最直观的想法就是遍历整个老年代,找到其中持有年轻代引用的对象,但显然这样做的开销太大,且违背了分代 GC 的设计。因此,垃圾回收器必须能够以较高的效率准确找到并跟踪那些处于老年代且持有年轻代引用的对象,并将这部分对象放到和 GC Roots 同等的位置,这就是 old-gen scanning 阶段的来历。

下图大致展示了 YGC 时是如何追踪和标记存活的对象的。图中的箭头表示对象之间的引用关系,其中红色箭头表示老年代到年轻代的引用,这部分对象会被添加到 old-gen scanning 中,而蓝色的箭头表示 GC Roots 或年轻代对象到老年代的引用,这部分对象在 YGC 阶段实际上是无需进行追踪的。



Card marking

回忆之前提到的分代假设,其中一条即是:存在少部分对象,可能会存活很长时间,并不太可能使用到年轻对象。这意味着,只有极少部分的老年代对象,会持有年轻代对象的引用,如果使用遍历整个老年代的方式找出这部分对象,显然效率十分低下。

一般而言,如下两种情况会使得老年代对象持有年轻代的引用:
  • 持有其他年轻代对象引用的对象被晋升到老年代
  • 某个老年代对象持有的引用被修改为指向某个年轻代对象

对于第一种情况,因为晋升本身就发生在 YGC 执行期间,垃圾回收器能够明确知晓哪些对象需要被晋升到老年代,而对于第二种情况,则需要依赖额外的设计。

在 HotSpot JVM 的实现中,ParNew 使用 Card marking 算法来识别老年代对象所持有引用的修改。在该算法中,老年代空间被分成大小为 512B 的若干个 card,并由 JVM 使用一个数组来维护其映射关系,数组中的每一位代表一个 card。每当修改堆中对象的引用时,就会将对应的 card 置为 dirty。当进行 YGC 时,只需要先通过扫描 card 数组,就可以很快识别出哪部分空间可能存在老年代对象持有年轻代对象引用的情况,通过空间换时间的方式,避免对整个老年代进行扫描。




YGC 优化
ParGCCardsPerStrideChunk 参数

既然 old-gen scanning 在 YGC 中占用大部分耗时,是 YGC 耗时高的主要原因,那么首先想到的是,能否通过调整参数加快 old-gen scanning 的扫描速度?

在 old-gen scanning 阶段,老年代会被切分为若干个大小相等的区域,每个工作线程负责处理其中的一部分,包括扫描对应的 card 数组以及扫描被标记为 dirty 的老年代空间。由于处理不同的老年代区域所需要的处理时间相差可能很大,为防止部分工作线程过于空闲,通常被切分出的老年代区域数需要大于工作线程的数目,而 ParGCCardsPerStrideChunk 参数则是用于控制被切分出的区域的大小。

默认情况下,ParGCCardsPerStrideChunk 的值为 256,由于每个card 对应 512 字节的老年代空间,因此在扫描时每个区域的大小为 128KB,对于 4GB 的堆,会存在超过 3 万个区域,比工作线程数足足高了 4 个数量级。下图即为将ParGCCardsPerStrideChunk参数分别设置为 256,2K,4K 和 8K 来运行 GC 基准测试[1],结果显示,默认值 256 在任何情况下都显得有些小,并且堆越大,GC 停顿时间相比其他值也会越长。




考虑到 MsgBroker 的堆大小为 4G,ParGCCardsPerStrideChunk设置为4K已经足够大。然而,在修改了 ParGCCardsPerStrideChunk 后,并没有取得预期内的效果,实际上 MsgBroker 的 YGC 耗时没有得到任何降低。这说明,被置为dirty的card可能非常多,破坏了 GC 的分代假设,使得扫描任务本身过于繁重,其耗费的时间远远大于工作线程频繁切换扫描区域的开销。

消息缓存优化

基于上面的猜测,我们将优化聚焦到了消息缓存上。为了避免消息缓存中消息数量过多导致 OOM,MsgBroker 基于 LinkedHashMap 实现了 LRU Cache 和 FIFO Cache 。众所周知,LinkedHashMap是 HashMap 的子类,并额外维护了一个双向链表用于保持迭代顺序,然而,这可能会带来以下三个问题:

  • 消息缓存中可能存在一些一直未投递成功的消息,这些消息对象都处于老年代;同时,当收到发送端的发消息请求时,MsgBroker 会将消息插入到缓存中,这部分消息对象处于年轻代。当不断向消息缓存中插入新的元素时,内部双向链表的引用关系会频繁发生变化,YGC 时会触发大规模的老年代扫描。
  • 当订阅端出现问题时,大量未投递成功的消息都会被缓存起来,即使存在 LRU 等淘汰机制,被淘汰出的消息也很有可能已经晋升到老年代,无论是 YGC 时拷贝、晋升的压力,还是 CMS GC 的频率,都会显著提升。
  • 不同业务所发送的消息的大小区别非常大,当订阅端出现问题时,会有大量消息被晋升到老年代,这可能会产生大量的内存碎片,甚至引发 FGC。

上述第一个和第二个问题会使得 YGC 时 old-gen scanning 阶段的扫描、拷贝成本更高,other 阶段晋升的对象更多,而第三个问题则会产生更多的内存碎使得 FGC 的概率升高。

既然消息缓存的插入、查询、移除、销毁都是由 MsgBroker 自己控制,那么,如果这部分内存不再委托给 JVM,而是完全由 MsgBroker 自行管理其生命周期,上述 GC 问题就都能得到解决。

谈到让 JVM 看不见,最直观的想法就是使用堆外解决方案。然而,在上面的场景中,如果仅仅只是将消息移动到堆外,是无法完全解决问题的。如果要解决上述所有问题,需要有一个完整运行在堆外的类似 LinkedHashMap 的数据结构,同时需要具备良好的并发访问能力,且不能有性能损失。

ohc 作为一个足够简单、侵入性低的堆外缓存库,最开始是 Apache Cassandra 的堆外内存分配方案,后来 Cassandra 将这块实现单独抽象出来,作为一个独立的包,使得其他有同样需求的应用也能使用。由于 ohc 提供了完整的堆外缓存实现,支持无锁的并发写入和查询,同时也支持LRU,十分契合 MsgBroker 的需求,本着不重复造轮子的原则,我们决定基于其实现堆外消息缓存。

与堆内消息缓存相比,使用堆外消息缓存会多一次内存拷贝的开销。不过,从实际的测试数据看,在给定的吞吐量下,堆外缓存下的 RT 并没有出现恶化,仅仅 CPU Util 略微有所提升(从 60% 升到 63%),完全在可以接受的范围内。
通过上述消息缓存优化,并将 ParGCCardsPerStrideChunk 参数设置为 4K 后,线上大部分机器的 YGC 耗时从 60ms 降低到 30ms 左右,同时 CMS GC 出现的频率也大大降低。

然而,对于那些 YGC 耗时特别高的机房中的机器,即使通过消息缓存优化,YGC 耗时也只是从 120ms 降低到 80ms 左右,耗时仍然偏高,且 old-gen scanning 阶段依然占用了绝大部分时间。


消息对象引用与生命周期的优化

通过对线上机器的 GC 情况进行观察和总结,我们发现,YGC 耗时在 50ms 左右的机器,连接数比较正常,基本都维持在 5000 左右,而那些 YGC 耗时为 120ms 左右的机器,其连接数接近甚至超过 20000。基于这些发现,YGC 问题很可能与通信层密切相关。

原本,MsgBroker 的网络通信层是使用自己开发的网络框架 Gecko,Gecko 默认会为每个网络连接分配 64KB 的内存,如果网络连接数过多,就会占用大量的内存,导致频繁 GC,严重限制了 MsgBroker 的性能。在这个背景下,MsgBroker 使用自研的 Bolt 网络框架(基于 Netty)对网络层进行了重构,默认将网络连接使用的内存分配到堆外,解决了高连接数下的性能问题。同时,Bolt 的基准性能测试也显示,即使在 100000 的连接数下,服务端的性能也不会受到连接数的影响。

如果通信框架本身不会遇到连接数的问题,那么很有可能是 MsgBroker 在对通信框架的使用上存在一些问题。通过 review 代码、dump 内存等手段,我们发现问题主要出在消息请求的 decode 上。

如下面的代码所示,在对消息请求进行 decode 时,RequestDecoder 会首先尝试解析消息的 header 部分,如果 byteBuf 中的数据足够,RequestDecoder 会将 header 完整解析出来,并保存在 requestCommand 中。这样,如果 byteBuf 中的数据不够解析出消息的 body 部分,下次 decode 时也可以直接从 body 部分开始,降低重复读取的开销。





RequestDecoder 持有 RequestCommand 的引用,本意是为了避免重复读取 byteBuf。然而,这却会带来以下问题:

  • RequestDecoder 基本都处于老年代,而 RequestCommand 处于年轻代。当服务端的某个连接不断接收发消息请求时,其老年代与年轻代之间的引用关系也会不断变换,这会加重 YGC 时的老年代扫描压力,连接数越多,压力越大。
  • 对于消息量较少的连接,虽然引用关系不会频繁变换,但由于 RequestDecoder 会长期持有某个 RequestCommand 的引用,使得该消息无法被及时回收,容易因达到一定年龄而晋升到老年代,这会加重 YGC 时的拷贝压力。同样,连接数越多,压力也越大。

其实解决思路也非常简单,让 RequestDecoder 不再持有对 RequestCommand 的引用。在 decode 时,如果 byteBuf 中可读取的内容不够完整解析出消息,则回滚读取 index 到初始位置并放弃本次 decode 操作,直到 byteBuf 中存在足够多的数据。这样虽然可能会存在重复读取,但与 GC 比起来,这点开销完全可以接受。

通过上述优化,即使是那些连接数特别高的机器,其 YGC 耗时也进一步从 80ms 下降到了 30ms。


订阅端异常场景下的自我保护

MsgBroker 作为推模式的消息中间件,无论何种情况都能够有效保证消息投递的实时性。但如果订阅端因为频繁 GC,CPU 或 IO 出现瓶颈,甚至下游链路 RT 变高导致消息的消费速度跟不上消息的生产速度,就容易使得大量被实时推送过来的消息堆积在订阅端的消息处理线程池队列中,而这其中的绝大部分消息,可能都还不及出队列得到被线程执行的机会,就已经被 MsgBroker 判定为投递超时,从而引发大量的投递超时错误,导致大量消息需要被重投。

当新产生的消息叠加上需要被重投的消息,会更加重订阅端的负担,使得因投递超时而需要被重投的消息越来越多,即使后续订阅端的消费能力恢复正常,也可能因为失败量过大导致需要很长的消化时间,如果失败持续时间过长,甚至可能引发这个消费链路的雪崩,订阅端无法再恢复正常。

尽管通过上述优化,能有效解决内存碎片问题,以及正常场景下的 YGC 耗时高问题。但在异常场景下,YGC 耗时仍然较高(在实验室构造的超时场景下,尽管连接数维持在个位数,YGC 平均耗时也上涨到了 147ms),在而通过上述优化手段,YGC 耗时也仅从 147ms 降低到了 83ms。通过进一步的分析,我们发现:

  • 由于 MsgBroker 的默认投递超时时间为 10s,与其他的投递失败不同,一旦出现大量投递超时,消息至少会在 MsgBroker 的内存中停留 10s,这会给 YGC 带来非常大的压力。
  • 由于投递失败后的更新操作是异步的,同时为了避免消息更新操作对消息新增造成影响,更新操作通常不会有太多的线程资源。当存在大量投递失败时,对消息的更新操作很可能因为任务量过大而积压在内存中。


为了解决上述问题,MsgBroker 实现了一种自适应投递限流算法,如下图所示。算法的基本思路就是服务端会不断根据订阅端的消费结果估计订阅端的消费能力,并按照估计出的订阅端消费能力进行限流投递,对于被限流的消息,能够快速失败掉,不必在内存中再停留 10s,同时也无需再执行 DB 更新操作。这样,即保护了订阅端,有利于积压消息的快速消化,也能保护服务端不受订阅端的影响,并进一步降低 DB 的压力。




通过引入自适应投递限流,在实验室测试环境下,MsgBroker 在异常场景下的 YGC 耗时进一步从 83ms 降低到 40ms,恢复了正常的水平。

YGC 优化总结

通过上面的 YGC 问题以及优化过程可以看出,YGC 的恶化,主要就在于应用中的对象违背了 GC 的分代假设,而上述所提及的所有优化手段,也是为了尽量让应用中的对象满足 GC 的分代假设。因此,在平时的研发活动中,程度的设计和实现都应该尽量满足分代假设。

reference


  • Garbage collection in the HotSpot JVM (https://www.ibm.com/developerworks/library/j-jtp11253/)
  • Secret HotSpot option improving GC pauses on large heaps (http://blog.ragozin.info/2012/03/secret-hotspot-option-improving-gc.html)
  • OHC - An off-heap-cache (https://github.com/snazy/ohc/)

公众号:金融级分布式架构(Antfin_SOFA)