OHC Java堆外緩存詳解與應用


1、背景

  在當前微服務體系架構中,有很多服務例如,在 特征組裝 與 排序等場景都需要有大量的數據支撐,快速讀取這些數據對提升整個服務於的性能起着至關重要的作用。

  緩存在各大系統中應用非常廣泛。尤其是業務程序所依賴的數據可能在各種類型的數據庫上(mysql、hive 等),那么如果想要獲取到這些數據需要通過網絡來訪問。再加上往往數據量又很龐大,網絡傳輸的耗時,自然會增加系統的相應時間。為了降低相應時間,業務程序可以將從數據庫中讀取到的部分數據,緩存在本地服務器可以很方便快速的直接使用。

  緩存框架OHC基於Java語言實現,並以類庫的形式供其他Java程序調用,是一種以單機模式運行的堆外緩存。

2、OHC 簡介

  緩存的分類與實現機制多種多樣,包括單機緩存與分布式緩存等等。具體到JVM應用,又可以分為堆內緩存和堆外緩存。

  OHC 全稱為 off-heap-cache,即堆外緩存,是一款基於Java 的 key-value 堆外緩存框架。

  OHC是2015年針對 Apache Cassandra 開發的緩存框架,后來從 Cassandra 項目中獨立出來,成為單獨的類庫,其項目地址為:https://github.com/snazy/ohc

 2.1、堆內和堆外

  Java程序運行時,由Java虛擬機(JVM)管理的內存區域稱為堆(heap)。垃圾收集器會掃描堆內空間,識別應用程序已經不再使用的對象,並釋放其空間,這個過程稱為GC。

  堆內緩存,顧名思義,是指將數據緩存在堆內的機制,比如 HashMap 就可以用作簡單的堆內緩存。由於垃圾收集器需要掃描堆,並且在掃描時需要暫停應用線程(stop-the-world,STW),因此,緩存數據過多會導致GC開銷增大,從而影響應用程序性能。

   與堆內空間不同,堆外空間不影響GC,由應用程序自身負責分配與釋放內存。因此,當緩存數據量較大(達到G以上級別)時,可以使用堆外緩存來提升性能。

  

 2.2、OHC 的特性

  相對於持久化數據庫,可用的內存空間更少、速度也更快,因此通常將訪問頻繁的數據放入堆外內存進行緩存,並保證緩存的時效性。OHC主要具有以下特性來滿足需求:

  1. 數據存儲在堆外,不影響GC
  2. 支持為每個緩存項設置過期時間
  3. 支持配置LRU、W-TinyLFU逐出策略
  4. 能夠維護大量的緩存條目(百萬量級以上)
  5. 支持異步加載緩存
  6. 讀寫速度在微秒級別

  OHC具有低延遲、容量大、不影響GC的特性,並且支持使用方根據自身業務需求進行靈活配置。

 2.3、使用示例

  在Java項目中使用OHC,主要包括以下步驟:

  1. 在項目中引入OHC。如果使用Maven來管理依賴,可以將OHC的坐標添加到到項目的POM文件中。

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

  2. OHC是將Java對象序列化后存儲在堆外,因此用戶需要實現 org.caffinitas.ohc.CacheSerializer 類,OHC會運用其實現類來序列化和反序列化對象。例如,以下例子是對 string 進行的序列化實現:

public class StringSerializer implements CacheSerializer<String> {
 
    /**
     * 計算字符串序列化后占用的空間
     *
     * @param value 需要序列化存儲的字符串
     * @return 序列化后的字節數
     */
    @Override
    public int serializedSize(String value) {
        byte[] bytes = value.getBytes(Charsets.UTF_8);
 
        // 設置字符串長度限制,2^16 = 65536
        if (bytes.length > 65536)
            throw new RuntimeException("encoded string too long: " + bytes.length + " bytes");
        // 設置字符串長度限制,2^16 = 65536
        return bytes.length + 2;
    }
 
    /**
     * 將字符串對象序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外內存區域的映射。
     *
     * @param value 需要序列化的對象
     * @param buf   序列化后的存儲空間
     */
    @Override
    public void serialize(String value, ByteBuffer buf) {
        // 得到字符串對象UTF-8編碼的字節數組
        byte[] bytes = value.getBytes(Charsets.UTF_8);
        // 用前16位記錄數組長度
        buf.put((byte) ((bytes.length >>> 8) & 0xFF));
        buf.put((byte) ((bytes.length) & 0xFF));
        buf.put(bytes);
    }
 
    /**
     * 對堆外緩存的字符串進行反序列化
     *
     * @param buf 字節數組所在的 ByteBuffer
     * @return 字符串對象.
     */
    @Override
    public String deserialize(ByteBuffer buf) {
        // 判斷字節數組的長度
        int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff)));
        byte[] bytes = new byte[length];
        // 讀取字節數組
        buf.get(bytes);
        // 返回字符串對象
        return new String(bytes, Charsets.UTF_8);
    }
}
View Code

  3. 將CacheSerializer的實現類作為參數,傳遞給OHCache的構造函數來創建OHCache

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
    }
}

  4. 使用OHCache的相關方法(get、put)來讀寫緩存。見第 3 點中的使用示例。

3、OHC 的底層原理

 3.1、整體架構

  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 實現在線上使用,后續介紹也以linked 實現為例,其整體架構及內存分布如下圖所示,下文將分別介紹其功能。

  

 3.2、OHCacheLinkedImpl

  OHCacheLinkedImpl是堆外緩存的具體實現類,其主要成員包括:

     段數組:OffHeapLinkedMap[]

     序列化器與反序列化器:CacheSerializer

  OHCacheLinkedImpl 中包含多個段,每個段用 OffHeapLinkedMap 來表示。同時,OHCacheLinkedImpl 將Java對象序列化成字節數組存儲在堆外,在該過程中需要使用用戶自定義的 CacheSerializer。OHCacheLinkedImpl 的主要工作流程如下:

   1、計算 key 的 hash值,根據 hash值 計算段號,確定其所處的 OffHeapLinkedMap

   2、從 OffHeapLinkedMap 中獲取該鍵值對的堆外內存指針

   3、對於 get 操作,從指針所指向的堆外內存讀取 byte[],把 byte[] 反序列化成對象

   4、對於 put 操作,把對象序列化成 byte[],並寫入指針所指向的堆外內存

  3.3、段的實現:OffHeapLinkedMap

   在OHC中,每個段用 OffHeapLinkedMap 來表示,段中包含多個分桶,每個桶是一個鏈表,鏈表中的元素即是緩存條目的堆外地址指針。OffHeapLinkedMap 的主要作用是根據 hash值 找到 鍵值對 的 堆外地址指針。在查找指針時,OffHeapLinkedMap 先根據 hash值 計算出 桶號,然后找到該桶的第一個元素,然后沿着第一個元素按順序線性查找。

 3.4、空間分配

  OHC 的 linked 實現為每個鍵值對分別分配堆外內存,因此鍵值對實際是零散地分布在堆外。

  OHC提供了JNANativeAllocator 和 UnsafeAllocator 這兩個分配器,分別使用 Native.malloc(size) 和 Unsafe.allocateMemory(size) 分配堆外內存,用戶可以通過配置來使用其中一種。

   OHC 會把 key 和 value 序列化成 byte[] 存儲到堆外,如2.3所述,用戶需要通過實現 CacheSerializer 來自定義類完成 序列化 和 反序列化。因此,占用的空間實際取決於用戶自定義的序列化方法。

  除了 key 和 value 本身占用的空間,OHC 還會對 key 進行 8位 對齊。比如用戶計算出 key 占用 3個字節,OHC會將其對齊到8個字節。另外,對於每個鍵值對,OHC需要額外的64個字節來維護偏移量等元數據。因此,對於每個鍵值對占用的堆外空間為:

每個條目占用堆外內存 = key占用內存(8位對齊) + value占用內存 + 64字節

 4、方案選型與應用

 4.1、適用於OHC存儲的數據

  針對我們線上常用的緩存:Redis 集群、OHC 和 Guava 來進行線上數據存儲,這三種存儲方式的特性分別如下:

   

  因為不同存儲方式的特性差別較大,我們會根據具體場景來從中選擇。特征組裝與排序引擎所需的數據主要分為“離線數據” 和 “實時數據”,均使用Redis作為主庫。離線數據由定時算法任務生成后寫入HDFS,一般按照小時級或者天級進行更新,並通過 XXL Job 和 DataX 定時從 HDFS 同步到 Redis 供使用。實時數據則根據用戶行為進行在線更新,通常使用 Flink 任務實時計算后直接寫入 Redis。

  

  對於離線數據,其更新周期比較長,非常適合使用OHC緩存到服務所在服務器本地。比如,在進行排序時,item的歷史點擊率是非常重要的特征數據,特別是其最近幾天的點擊率。這種以天為單位更新的離線特征,如果使用OHC緩存到本地,則可以避免讀取Redis的網絡開銷,節省排序階段耗時。

   對於實時數據,其更新受用戶實時行為影響,下次更新時間是不確定的。比如用戶對某個目的地的偏好程度,這種數據隨着用戶在App端不斷進行點擊而更新。這種實時數據不會使用OHC緩存到本地,否則可能會導致 本地緩存 和 主庫的數據不一致。即使進行緩存,也應該設置較小的過期時間(比如秒級或者分鍾級),盡量保證數據的實時性和准確性。

   其他數據,比如站內的高熱數據 和 兜底數據,其數據量較小且可能頻繁使用,這種數據我們使用 Guava 緩存到堆內,以便於快速讀取。

  4.2、序列化工具的選擇

  如上文所說,OHC 是一款 key-value 形式的緩存框架,並且對 key 和 value 都提供了泛型支持。因此,使用方在創建 OHC對象時就需要確定 key 和 value 的類型。

   一般使用場景中,使用OHC時 key 設置為 String 類型,value 則設置為 Object類型,從而可以存儲各種類型的對象。由於 OHC 需要把 key 和 value 序列化成字節數組存儲到堆外,因此需要選擇合適的序列化工具。

  對於String類型的key,其序列化過程比較簡單,可以直接轉換成UTF-8格式的字節數組來表示。對於Object類型的 value,則選用了開源的 Kyro 作為序列化工具。需要注意的是,由於Kyro不是線程安全的,可以搭配ThreadLocal一起使用。

   在使用OHC時,通常有兩個地方用到序列化。在存儲每個鍵值對時,會調用 CacheSerializer#serializedSize 計算序列化后的內存空間占用,從而申請堆外內存。另外,在真正寫入堆外時,會調用 CacheSerializer#serialize 真正進行序列化。因此,務必在這兩個方法中使用相同的序列化方法。

  也就是說,申請的堆外內存 (CacheSerializer#serializedSize 計算所得) 和 實際占用的堆外內存(CacheSerializer#serialize) 要保持一致。我們開始使用 OHC 時,在 CacheSerializer#serializedSize方法中使用com.twitter.common.objectsize.ObjectSizeCalculator 計算序列化后的空間占用,而在 CacheSerializer#serialize 中則使用了Kryo。結果發現 ObjectSizeCalculator 計算的內存遠遠大於Kyro計算出來的,導致為每個鍵值對申請了大量堆外內存卻沒有充分使用。

 4.3、生產環境的配置

  OHC支持大量配置選項,供使用方根據自身業務場景進行選擇,這里介紹下在我們業務中相關參數的配置。

  

   總容量

最開始使用OHC時,我們設置的上限為4G左右。隨着業務的發展和數據量的增長,逐漸增大到10G,基本可以覆蓋熱點數據。

  段數量

一方面,OHC使用了分段鎖,多個線程訪問同一個段時會導致競爭,所以段數量不宜設置過小。同時,當段內條目數量達到一定負載時 OHC 會自動 rehash,段數量過小則會允許段內存儲的條目數量增加,從而可能導致段內頻繁進行rehash,影響性能。另一方面,段的元數據是存儲在堆內的,過大的段數量會占用堆內空間。因此,應該在盡量減少rehash的次數的前提下,結合業務的QPS等參數,將段數量設置為較小的值。

   哈希算法

通過壓測,我們發現使用 CRC32、CRC32C 和 MURMUR3 時,鍵值對的分布都比較均勻,而 CRC32C 的 CPU使用率相對較低,因此使用 CRC32C 作為哈希算法。

   逐出算法

選用10G的總容量,基本已經覆蓋了大部分熱點數據,並且很少出現偶發性或者周期性的批量操作,因此選用了LRU。

 4.4、線上表現

  使用OHC管理的單機堆外內存在 10G 左右,可以緩存的條目為 百萬量級。我們主要關注 命中率、讀取 和 寫入速度 這幾個指標。

  OHC#stats 方法會返回 OHCacheStats 對象,其中包含了命中率等指標。

  當內存配置為10G時,在我們的業務場景下,緩存命中率可以穩定在95%以上。同時,我們在調用 get 和 put 方法時,進行了日志記錄,get 的平均耗時穩定在 20微妙 左右,put 則需要 100微妙。

  需要注意的是,get 和 put 的速度 和 緩存的鍵值對大小呈正相關趨勢,因此不建議緩存過大的內容。可以通過org.caffinitas.ohc.maxEntrySize 配置項,來限制存儲的最大鍵值對,OHC發現單個條目超過該值時不會將其放入堆外緩存

 4.5、實踐優化

  (1)異步移除過期數據

   在 OffHeapLinkedMap 的原始實現中,讀取鍵值對 時 會判斷其是否過期,如果過期則立即將其移除。移除鍵值對是相對比較 “昂貴” 的操作,可能會阻塞當前讀取線程,因此我們對其進行了異步改造。讀取鍵值對時,如果發現其已經過期,則會將其存入一個隊列。同時,在后台加入了一個清理線程,定期從隊列里面讀取過期內容並進行移除。

   (2)加鎖方式優化

   OHC本身是線程安全的,因為每個段都有自己的鎖,在讀取 和 寫入時都會加鎖。其源代碼中使用的是 CAS鎖(compare-and-set),在更新失敗時嘗試掛起線程並重試:

  

   每個線程都有自己的緩存,當變量標記為臟時線程會更新緩存。但是,無論是否成功設置該值,CAS鎖在每次調用變量時都會將其標記為臟數據,這會導致在線程競爭激烈時性能下降。使用 CASC(compare-and-set-compare)鎖可以盡量減少 CAS 的次數,從而提高性能:

  

5、線上配置參數

-XX:MaxDirectMemorySize=9g
-Dorg.caffinitas.ohc.capacity=9126805504   # 5G=5368709120 7G=7516192768 8.5G=9126805504 
-Dorg.caffinitas.ohc.maxEntrySize=2621440  # 10M
-Dorg.caffinitas.ohc.segmentCount=1024
-Dorg.caffinitas.ohc.hashAlgorighm=CRC32C
-Dorg.caffinitas.ohc.eviction=W_TINY_LFU
-Dorg.caffinitas.ohc.edenSize=0.1

  相關的配置參數含義請參考 4.3 中的內容。這里只說一下:MaxDirectMemorySize

  -XX:MaxDirectMemorySize=size 用於設置 New I/O(java.nio)  direct-buffer allocations 的最大大小,size的單位可以使用 k/K、m/M、g/G;

  如果沒有設置該參數則默認值為0,意味着JVM自己自動給NIO direct-buffer allocations選擇最大大小;

  從代碼java.base/jdk/internal/misc/VM.java中可以看到默認是取的Runtime.getRuntime().maxMemory()

6、總結

  OHC是一款Java實現的堆外緩存框架,具有低時延、不影響GC的特點,適合存儲大量緩存條目,同時支持配置過期時間、逐出算法等多個配置項。

  同時我們注意到,相對於另一款開源的緩存框架 ehcache,OHC的中文資料相對較少。我們在框架選型時也對兩者進行了壓測,OHC在我們業務場景下性能表現更好,因此選擇 OHC作為我們的主要緩存實現。

  特別地,對於推薦引擎這種依賴大量 離線數據 和 實時數據的應用,OHC適合將離線數據進行本地緩存,從而節省訪問遠程數據庫的時間。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM