撮合引擎純內存計算帶來的GC問題

本文主要是介紹交易所內存撮合引擎中,大量的訂單匹配撮合的過程對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:

<dependency>
    <groupId>org.caffinitas.ohc</groupId>
    <artifactId>ohc-core</artifactId>
    <version>0.7.0</version>
</dependency>

簡單demo,具體實例看官方的哈:

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 依賴如下:

<dependency>
    <groupId>org.ehcache</groupId>
    <artifactId>ehcache</artifactId>
    <version>3.9.0</version>
</dependency>
使用樣例:
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。

-XX:+UseG1GC -XX:G1HeapRegionSize=16m   
-XX:G1ReservePercent=25 
-XX:InitiatingHeapOccupancyPercent=30

這些GC選項看起來有點激進,但事實證明它在我們的生產環境中具有良好的性能。另外不要把-XX:MaxGCPauseMillis的值設置太小,否則JVM將使用一個小的年輕代來實現這個目標,這將導致非常頻繁的minor GC,所以建議使用rolling GC日誌文件:

-XX:+UseGCLogFileRotation   
-XX:NumberOfGCLogFiles=5 
-XX:GCLogFileSize=30m

如果寫入GC文件會增加代理的延遲,可以考慮將GC日誌文件重定向到內存文件系統:

-Xloggc:/dev/shm/ex_gc_%mxs.log

如果你是JDK11的話,還可以開啓這些參數,指針壓縮減少運行內存減少gc耗時 ,打開ExplicitGCInvokesConcurrent 此參數後,在做System.gc()時會做background模式CMS GC,即並行FULL GC,可提高FULL GC效率。注,該參數在允許systemGC且使用CMS GC時有效:

-XX:+UseCompressedOops
-XX:+UseCompressedClassPointers
-XX:+ExplicitGCInvokesConcurrent

JDK11的ZGC使用配置:

-XX:+UnlockExperimentalVMOptions 
-XX:+UseZGC

當然除了訂單等一系列的存儲問題,我們還存在一些內存計算邏輯,一些對象應用的頻繁變化等等都是我們優化的方向,如果你們有更好的建議,和想法歡迎大家評論留言,THX

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章