撮合引擎纯内存计算带来的GC问题

2021-01-26 16:00:21 浏览数 (1)

本文主要是介绍交易所内存撮合引擎中,大量的订单匹配撮合的过程对GC的影响

在撮合引擎运行的过程中,有大量的不能成交的单子,会被挂在订单薄上并上时间不能被撮合,这些单子会进入老年代且每次新的单子来了都将作为计算和匹配的因子。随着订单薄的单子的增长,我们发现撮合引擎的 YGC 平均耗时也会不停增长。

那么消息进入老年代,出现堆积,为何会导致YGC时间过长呢?

  1. 在YGC阶段,涉及到垃圾标记的过程,从GCRoot开始标记。
  2. 因为YGC不涉及到老年代的回收,一旦从GCRoot扫描到引用了老年代对象时,就中断本次扫描。这样做可以减少扫描范围,加速YGC。
  3. 存在被老年代对象引用的年轻代对象,它们没有被GCRoot直接或者间接引用。
  4. YGC阶段中的old-gen scanning即用于扫描被老年代引用的年轻代对象。
  5. old-gen scanning扫描时间与老年代内存占用大小成正比。
  6. 得到结论,老年代内存占用增大会导致YGC时间变长。

总的来说,将消息缓存在JVM内存会对垃圾回收造成一定影响:

  1. 委托单消息最初缓存到年轻代,会增加YGC的频率。
  2. 委托单消息被提升到老年代,会增加FGC的频率。
  3. 老年代的消息增长后,会延长old-gen scanning时间,从而增加YGC耗时。

可以看出 old-gen scanning 在 YGC 中占用大部分耗时,是 YGC 耗时高的主要原因,那么能否通过调整参数加快 old-gen scanning 的扫描速度?

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

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

为了避免委托单消息缓存中消息数量过多导致 OOM ,委托单插入、查询、移除、销毁都是由撮合引擎自己控制。那么这部分内存不再委托给 JVM,而是完全由 撮合引擎自行管理其生命周期,那么委托单量造成的GC问题就得到了解决。

最直观的想法就是使用堆外解决方案。然而在交易所场景中,如果仅仅只是将消息移动到堆外,是无法完全解决问题的。首先需要具备良好的快速访问能力、容量大且不能有性能损失,当然如果支持自定义排序当然更好了。

OHC 全称为 off-heap-cache,即堆外缓存,是一款基于Java 的 key-value 堆外缓存框架。 OHC 是2015年针对 Apache Cassandra 开发的缓存框架,后来从 Cassandra 项目中独立出来,成为单独的类库,其项目地址为:https://github.com/snazy/ohc 当然我也并未发现有想sortMap一样的功能的堆外内存框架,所以我们还是需要在jvm中维护一套内存应用其实只需维护价格和数量。这样old-gen scanning中的对象就大量的少了。

OHC使用示例

项目中引入依赖POM:

代码语言:javascript复制
<dependency>
    <groupId>org.caffinitas.ohc</groupId>
    <artifactId>ohc-core</artifactId>
    <version>0.7.0</version>
</dependency>

简单demo,具体实例看官方的哈:

代码语言:javascript复制
import org.caffinitas.ohc.Eviction;
import org.caffinitas.ohc.OHCache;
import org.caffinitas.ohc.OHCacheBuilder;

public class OffHeapCacheExample {

    public static void main(String[] args) {
        OHCache<String, String> ohCache = OHCacheBuilder.<String, String>newBuilder()
                .keySerializer(new StringSerializer())
                .valueSerializer(new StringSerializer())
                .eviction(Eviction.LRU)
                .build();

        ohCache.put("hello", "world");
        System.out.println(ohCache.get("hello")); // world
    }
}

OHC 以 API 的方式供其他 Java 程序调用,其 org.caffinitas.ohc.OHCache 接口定义了可调用的方法。对于缓存来说,最常用的是 get 和 put 方法。针对不同的使用场景,OHC提供了两种OHCache的实现: org.caffinitas.ohc.chunked.OHCacheChunkedImpl org.caffinitas.ohc.linked.OHCacheLinkedImpl 以上两种实现均把所有条目缓存在堆外,堆内通过指向堆外的地址指针对缓存条目进行管理。 其中linked 实现为每个键值对分别分配堆外内存,适合中大型键值对。chunked 实现为每个段分配堆外内存,适用于存储小型键值对。由于 chunked 实现仍然处于实验阶段,所以只能选择 linked 实现在线上使用。 使用OHC管理的单机堆外内存在 10G 左右,可以缓存的条目为 百万量级。我们主要关注读写性能。 OHC#stats 方法会返回 OHCacheStats 对象,其中包含了命中率等指标。 当内存配置为10G时,在调用 get 和 put 方法时,进行了日志记录,get 的平均耗时稳定在 20微妙 左右,put 则需要 100微妙。

EhCache使用

相信这个东西大家都使用过了,EhCache 是老牌Java开源缓存框架,早在2003年就已经出现了,发展到现在已经非常成熟稳定,在Java应用领域应用也非常广泛,而且和主流的Java框架比如Srping可以很好集成。相比于 Guava Cache,EnCache 支持的功能更丰富,包括堆外缓存、磁盘缓存,当然使用起来要更重一些。使用 Ehcache 的Maven 依赖如下:

代码语言:javascript复制
<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.9.0</version>
</dependency>

使用样例:

代码语言:javascript复制
CacheManagerConfiguration<PersistentCacheManager> persistentManagerConfig = CacheManagerBuilder
        .persistence(new File("/users/kinbug/Desktop", "ehcache-cache"));

PersistentCacheManager persistentCacheManager = CacheManagerBuilder.newCacheManagerBuilder()
        .with(persistentManagerConfig).build(true);

//disk 第三个参数设置为 true 表示将数据持久化到磁盘上
ResourcePoolsBuilder resource = ResourcePoolsBuilder.newResourcePoolsBuilder().disk(100, MemoryUnit.MB, true);

CacheConfiguration<String, String> config = CacheConfigurationBuilder
        .newCacheConfigurationBuilder(String.class, String.class, resource).build();
Cache<String, String> cache = persistentCacheManager.createCache("userInfo",
        CacheConfigurationBuilder.newCacheConfigurationBuilder(config));

cache.put("orderId", "order序列化对象");
System.out.println(cache.get("orderId"));
persistentCacheManager.close();
  • ResourcePoolsBuilder.heap(10)设置缓存的最大条目数,等价于ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, EntryUnit.ENTRIES);
  • ResourcePoolsBuilder.newResourcePoolsBuilder().heap(10, MemoryUnit.MB)设置缓存最大的空间10MB
  • withExpiry(ExpiryPolicyBuilder.timeToIdleExpiration(Duration.ofMinutes(10))) 设置缓存空闲时间
  • withExpiry(ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofMinutes(10))) 设置缓存存活时间
  • remove/removeAll主动失效缓存,与Guava Cache类似,调用方法后不会立即去清除回收,只有在get或者put的时候判断缓存是否过期
  • withSizeOfMaxObjectSize(10,MemoryUnit.KB)限制单个缓存对象的大小,超过这两个限制的对象则不被缓存

PS:在JVM停止时,一定要记得调用persistentCacheManager.close(),保证内存中的数据能够dump到磁盘上。 Ehcache我并没测试过举例性能,不过想来差距应该不大。

因为使用堆外内存有存磁盘等过程,所以建议使用SSD,SSD目前发展日益成熟,相较于HDD,SSD的IOPS与带宽拥有数量级级别的提升, 拥有比HDD更好的读写带宽与IOPS。

最后:使用 CRC32、CRC32C 和 MURMUR3 时,键值对的分布都比较均匀,而 CRC32C 的 CPU使用率相对较低,因此使用 CRC32C 作为哈希算法。

当然出了堆外内存,对于堆内存,我们也应该有一些优化:

通过“预触摸”Java堆以确保在JVM初始化期间每个页面都将被分配。那些不关心启动时间的人可以启用它:​ -XX: AlwaysPreTouch 禁用偏置锁定可能会减少JVM暂停,​ -XX:-UseBiasedLocking 至于垃圾回收,建议使用带JDK 1.8的G1收集器。 当然你是JDK11的话,建议使用ZGC。

代码语言:javascript复制
-XX: UseG1GC -XX:G1HeapRegionSize=16m   
-XX:G1ReservePercent=25 
-XX:InitiatingHeapOccupancyPercent=30

这些GC选项看起来有点激进,但事实证明它在我们的生产环境中具有良好的性能。另外不要把-XX:MaxGCPauseMillis的值设置太小,否则JVM将使用一个小的年轻代来实现这个目标,这将导致非常频繁的minor GC,所以建议使用rolling GC日志文件:

代码语言:javascript复制
-XX: UseGCLogFileRotation   
-XX:NumberOfGCLogFiles=5 
-XX:GCLogFileSize=30m

如果写入GC文件会增加代理的延迟,可以考虑将GC日志文件重定向到内存文件系统:

代码语言:javascript复制
-Xloggc:/dev/shm/mq_gc_%mxs.log

如果你是JDK11的话,还可以开启这些参数,指针压缩减少运行内存减少gc耗时 ,打开ExplicitGCInvokesConcurrent 此参数后,在做System.gc()时会做background模式CMS GC,即并行FULL GC,可提高FULL GC效率。注,该参数在允许systemGC且使用CMS GC时有效:

代码语言:javascript复制
-XX: UseCompressedOops
-XX: UseCompressedClassPointers
-XX: ExplicitGCInvokesConcurrent

JDK11的ZGC使用配置:

代码语言:javascript复制
-XX: UnlockExperimentalVMOptions 
-XX: UseZGC

当然除了订单等一系列的存储问题,我们还存在一些内存计算逻辑,一些对象应用的频繁变化等等都是我们优化的方向,如果你们有更好的建议,和想法欢迎大家评论留言,THX

0 人点赞