性能優(yōu)化是一場永無止境的旅程。
到家門店系統(tǒng),作為到家核心基礎(chǔ)服務(wù)之一,門店C端接口有著調(diào)用量高,性能要求高的特點。
C端服務(wù)經(jīng)過演進,核心接口先查詢本地緩存,如果本地緩存沒有命中,再查詢Redis。本地緩存命中率99%,服務(wù)性能比較平穩(wěn)。
隨著門店數(shù)據(jù)越來越多,本地緩存容量逐漸增大到3G左右。雖然對垃圾回收器和JVM參數(shù)都進行調(diào)整,由于本地緩存數(shù)據(jù)量越來越大,本地緩存數(shù)據(jù)對于應(yīng)用GC的影響越來越明顯,YGC平均耗時100ms,特別是大促期間調(diào)用方接口毛刺感知也越來越明顯。
由于本地緩存在每臺機器上容量是固定的,即便是將機器擴容,對與GC毛刺也沒有明顯效果。
本地緩存位于應(yīng)用程序的內(nèi)存中,讀取和寫入速度非常快,可以快速響應(yīng)請求,無需額外的網(wǎng)絡(luò)通信,但是一般本地緩存存在JVM內(nèi),數(shù)據(jù)量過多會影響GC,造成GC頻率、耗時增加;如果用Redis的話有網(wǎng)絡(luò)通信的開銷。
框架 | 簡介 | 特點 | 堆外緩存 | 性能(一般情況) |
Guava Cache | Guava Cache是Google的本地緩存庫,提供了基本的緩存功能。它簡單易用、輕量級,并支持基本的緩存操作。 | ·支持最大容量限制 ·支持兩種過期刪除策略(插入時間和訪問時間) ·支持簡單的統(tǒng)計功能 ·基于LRU算法實現(xiàn) | 不支持 | 性能中等 |
Caffeine | Caffeine是一個高性能的本地緩存庫,提供了豐富的功能和配置選項。它支持高并發(fā)性能、低延遲和一些高級功能,如緩存過期、異步刷新和緩存統(tǒng)計等。 | ·提供了豐富的功能和配置選項;高并發(fā)性能和低延遲;支持緩存過期、異步刷新和緩存統(tǒng)計等功能; ·基于java8實現(xiàn)的新一代緩存工具,緩存性能接近理論最優(yōu)。 ·可以看作是Guava Cache的增強版,功能上兩者類似,不同的是Caffeine采用了一種結(jié)合LRU、LFU優(yōu)點的算法:W-TinyLFU,在性能上有明顯的優(yōu)越性 | 不支持 | 性能出色 |
Ehcache | Encache是一個純Java的進程內(nèi)緩存框架,具有快速、精干等特點,是Hibernate中默認的CacheProvider。同Caffeine和Guava Cache相比,Encache的功能更加豐富,擴展性更強 | ·支持多種緩存淘汰算法,包括LRU、LFU和FIFO ·緩存支持堆內(nèi)存儲、堆外存儲、磁盤存儲(支持持久化)三種 ·支持多種集群方案,解決數(shù)據(jù)共享問題 | 支持 | 性能一般 |
OHC | OHC(Off-Heap Cache)是一個高性能的堆外緩存庫,專為高并發(fā)和低延遲而設(shè)計。它使用堆外內(nèi)存和自定義的數(shù)據(jù)結(jié)構(gòu)來提供出色的性能 | ·針對高并發(fā)和低延遲進行了優(yōu)化;使用自定義數(shù)據(jù)結(jié)構(gòu)和無鎖并發(fā)控制;較低的GC開銷; ·在高并發(fā)和低延遲的緩存訪問場景下表現(xiàn)出色 | 支持 | 性能最佳 |
通過對本地緩存的調(diào)研,堆外緩存可以很好兼顧上面的問題。堆外緩存把數(shù)據(jù)放在JVM堆外的,緩存數(shù)據(jù)對GC影響較小,同時它是在機器內(nèi)存中的,相對與Redis也沒有網(wǎng)絡(luò)開銷,最終選擇OHC。
talk is cheap, show me the code! OCH是騾子是馬我們遛一遛。
OHC 存儲的是二進制數(shù)組,需要實現(xiàn)OHC序列化接口,將緩存數(shù)據(jù)與二進制數(shù)組之間序列化和反序列化。
這里使用的是Protostuff,當然也可以使用kryo、Hession等,通過壓測驗證選擇適合的序列化框架。
<!--OHC相關(guān)--><dependency> <groupId>org.caffinitas.ohc</groupId> <artifactId>ohc-core</artifactId> <version>0.7.4</version></dependency><!--OHC 存儲的是二進制數(shù)組,所以需要實現(xiàn)OHC序列化接口,將緩存數(shù)據(jù)與二進制數(shù)組之間序列化和反序列化--><!--這里使用的是protostuff,當然也可以使用kryo、Hession等,通過壓測驗證選擇適合的--><dependency> <groupId>io.protostuff</groupId> <artifactId>protostuff-core</artifactId> <version>1.6.0</version></dependency><dependency> <groupId>io.protostuff</groupId> <artifactId>protostuff-runtime</artifactId> <version>1.6.0</version></dependency>
OHC緩存創(chuàng)建
OHCache<String, XxxxInfo> basicStoreInfoCache = OHCacheBuilder.<String, XxxxInfo>newBuilder() .keySerializer(new OhcStringSerializer()) //key的序列化器 .valueSerializer(new OhcProtostuffXxxxInfoSerializer()) //value的序列化器 .segmentCount(512) // 分段數(shù)量 默認=2*CPU核數(shù) .hashTableSize(100000)// 哈希表大小 默認=8192 .capacity(1024 * 1024 * 1024) //緩存容量 單位B 默認64MB .eviction(Eviction.LRU) // 淘汰策略 可選LRU/W_TINY_LFU/NONE .timeouts(false) //不使用過期時間,根據(jù)業(yè)務(wù)自己選擇 .build();
自定義序列化器,這里key-String 序列化器,這里直接復用OCH源碼中測試用例的String序列化器;
value-自定義對象序列化器,這里用Protostuff實現(xiàn),也可以自己選擇使用kryo、Hession等實現(xiàn);
//key-String 序列化器,這里直接復用OCH源碼中測試用例的String序列化器public class OhcStringSerializer implements CacheSerializer<String> { @Override public int serializedSize(String value) { return writeUTFLen(value); } @Override public void serialize(String value, ByteBuffer buf) { // 得到字符串對象UTF-8編碼的字節(jié)數(shù)組 byte[] bytes = value.getBytes(Charsets.UTF_8); buf.put((byte) ((bytes.length >>> 8) & 0xFF)); buf.put((byte) ((bytes.length >>> 0) & 0xFF)); buf.put(bytes); } @Override public String deserialize(ByteBuffer buf) { int length = (((buf.get() & 0xff) << 8) + ((buf.get() & 0xff) << 0)); byte[] bytes = new byte[length]; buf.get(bytes); return new String(bytes, Charsets.UTF_8); } static int writeUTFLen(String str) { int strlen = str.length(); int utflen = 0; int c; for (int i = 0; i < strlen; i++) { c = str.charAt(i); if ((c >= 0x0001) && (c <= 0x007F)){ utflen++;} else if (c > 0x07FF){ utflen += 3;} else{ utflen += 2; } } if (utflen > 65535) { throw new RuntimeException("encoded string too long: " + utflen + " bytes"); } return utflen + 2; }}//value-自定義對象序列化器,這里用Protostuff實現(xiàn),可以自己選擇使用kryo、Hession等實現(xiàn)public class OhcProtostuffXxxxInfoSerializer implements CacheSerializer<XxxxInfo> { /** * 將緩存數(shù)據(jù)序列化到 ByteBuffer 中,ByteBuffer是OHC管理的堆外內(nèi)存區(qū)域的映射。 */ @Override public void serialize(XxxxInfo t, ByteBuffer byteBuffer) { byteBuffer.put(ProtostuffUtils.serialize(t)); } /** * 對堆外緩存的數(shù)據(jù)進行反序列化 */ @Override public XxxxInfo deserialize(ByteBuffer byteBuffer) { byte[] bytes = new byte[byteBuffer.remaining()]; byteBuffer.get(bytes); return ProtostuffUtils.deserialize(bytes, XxxxInfo.class); } /** * 計算字序列化后占用的空間 */ @Override public int serializedSize(XxxxInfo t) { return ProtostuffUtils.serialize(t).length; }}
為了方便調(diào)用和序列化封裝為工具類,同時對代碼通過FastThreadLocal進行優(yōu)化,提升性能。
public class ProtostuffUtils { /** * 避免每次序列化都重新申請Buffer空間,提升性能 */ private static final FastThreadLocal<LinkedBuffer> bufferPool = new FastThreadLocal<LinkedBuffer>() { @Override protected LinkedBuffer initialValue() throws Exception { return LinkedBuffer.allocate(4 * 2 * LinkedBuffer.DEFAULT_BUFFER_SIZE); } }; /** * 緩存Schema */ private static Map<Class<?>, Schema<?>> schemaCache = new ConcurrentHashMap<>(); /** * 序列化方法,把指定對象序列化成字節(jié)數(shù)組 */ @SuppressWarnings("unchecked") public static <T> byte[] serialize(T obj) { Class<T> clazz = (Class<T>) obj.getClass(); Schema<T> schema = getSchema(clazz); byte[] data; LinkedBuffer linkedBuffer = null; try { linkedBuffer = bufferPool.get(); data = ProtostuffIOUtil.toByteArray(obj, schema, linkedBuffer); } finally { if (Objects.nonNull(linkedBuffer)) { linkedBuffer.clear(); } } return data; } /** * 反序列化方法,將字節(jié)數(shù)組反序列化成指定Class類型 */ public static <T> T deserialize(byte[] data, Class<T> clazz) { Schema<T> schema = getSchema(clazz); T obj = schema.newMessage(); ProtostuffIOUtil.mergeFrom(data, obj, schema); return obj; } @SuppressWarnings("unchecked") private static <T> Schema<T> getSchema(Class<T> clazz) { Schema<T> schema = (Schema<T>) schemaCache.get(clazz); if (Objects.isNull(schema)) { schema = RuntimeSchema.getSchema(clazz); if (Objects.nonNull(schema)) { schemaCache.put(clazz, schema); } } return schema; }}
通過壓測并逐步調(diào)整OHC配置常見參數(shù)(segmentCount、hashTableSize、eviction,參數(shù)含義見附錄)
MAX對比降低10倍
GC時間對比降低10倍
優(yōu)化前
優(yōu)化后
OHC緩存的命中次數(shù)、內(nèi)存使用狀態(tài)等存儲在OHCacheStats中,可以通過OHCache.stats()獲取。
OHCacheStates信息:
hitCount:緩存命中次數(shù),表示從緩存中成功獲取數(shù)據(jù)的次數(shù)。 missCount:緩存未命中次數(shù),表示嘗試從緩存中獲取數(shù)據(jù)但未找到的次數(shù)。 evictionCount:緩存驅(qū)逐次數(shù),表示因為緩存空間不足而從緩存中移除的數(shù)據(jù)項數(shù)量。 expireCount:緩存過期次數(shù),表示因為緩存數(shù)據(jù)過期而從緩存中移除的數(shù)據(jù)項數(shù)量。 size:緩存當前存儲的數(shù)據(jù)項數(shù)量。 capacity:緩存的最大容量,表示緩存可以存儲的最大數(shù)據(jù)項數(shù)量。 free:緩存剩余空閑容量,表示緩存中未使用的可用空間。 rehashCount:重新哈希次數(shù),表示進行哈希表重新分配的次數(shù)。 put(add/replace/fail):數(shù)據(jù)項添加/替換/失敗的次數(shù)。 removeCount:緩存移除次數(shù),表示從緩存中移除數(shù)據(jù)項的次數(shù)。 segmentSizes(#/min/max/avg):段大小統(tǒng)計信息,包括段的數(shù)量、最小大小、最大大小和平均大小。 totalAllocated:已分配的總內(nèi)存大小,表示為負數(shù)時表示未知。 lruCompactions:LRU 壓縮次數(shù),表示進行 LRU 壓縮的次數(shù)。
通過定期采集OHCacheStates信息,來監(jiān)控本地緩存數(shù)據(jù)、命中率=[命中次數(shù) / (命中次數(shù) + 未命中次數(shù))]等,并添加相關(guān)報警。同時通過緩存狀態(tài)信息,來判斷過期策略、段數(shù)、容量等設(shè)置是否合理,命中率是否符合預期等。
堆外緩存框架(Off-Heap Cache)是將緩存數(shù)據(jù)存儲在 JVM 堆外的內(nèi)存區(qū)域,而不是存儲在 JVM 堆中。在 OHC(Off-Heap Cache)中,數(shù)據(jù)也是存儲在堆外的內(nèi)存區(qū)域。
具體來說,OHC 使用 DirectByteBuffer 來分配堆外內(nèi)存,并將緩存數(shù)據(jù)存儲在這些 DirectByteBuffer 中。
DirectByteBuffer 在 JVM 堆外的內(nèi)存區(qū)域中分配一塊連續(xù)的內(nèi)存空間,緩存數(shù)據(jù)被存儲在這個內(nèi)存區(qū)域中。這使得 OHC 在處理大量數(shù)據(jù)時具有更高的性能和效率,因為它可以避免 JVM 堆的垃圾回收和堆內(nèi)存限制。
OHC 核心OHCache接口提供了兩種實現(xiàn):
?OHCacheLinkedImpl: 實現(xiàn)為每個條目單獨分配堆外內(nèi)存,最適合中型和大型條目。
?OHCacheChunkedImpl:實現(xiàn)為每個散列段作為一個整體分配堆外內(nèi)存,并且適用于小條目。(實驗性的,不推薦,不做關(guān)注)
可以看到OHCacheLinkedImpl中包含多個段,每個段用OffHeapLinkedMap來表示。同時,OHCacheLinkedImpl將Java對象序列化成字節(jié)數(shù)組存儲在堆外,在該過程中需要使用用戶自定義的CacheSerializer。
OHCacheLinkedImpl的主要工作流程如下:
1.計算key的hash值,根據(jù)hash值計算段號,確定其所處的OffHeapLinkedMap
2.從OffHeapLinkedMap中獲取該鍵值對的堆外內(nèi)存地址(指針)
3.對于get操作,從指針所指向的堆外內(nèi)存讀取byte[],把byte[]反序列化成對象
4.對于put操作,把對象序列化成byte[],并寫入指針所指向的堆外內(nèi)存
可以將OHC理解為一個key-value結(jié)果的map,只不過這個map數(shù)據(jù)存儲是指向堆外內(nèi)存的內(nèi)存指針。
指針在堆內(nèi),指針指向的緩存數(shù)據(jù)存儲在堆外。那么OHC最核心的其實就是對堆外內(nèi)存的地址引用的put和get以及發(fā)生在其中內(nèi)存空間的操作了。
對OHCacheLinkedImpl的put、get本地調(diào)試
put核心操作就是
1.申請堆外內(nèi)存
2.將申請地址存入map;
3.異常時釋放內(nèi)存
第2步其實就是map數(shù)據(jù)更新、擴容等的一些實現(xiàn)這里不在關(guān)注,我們重點關(guān)注怎么申請和釋放內(nèi)存的
通過深入代碼發(fā)現(xiàn)是調(diào)用的IAllocator接口的JNANativeAllocator實現(xiàn)類,最后調(diào)用的是com.sun.jna.Native#malloc實現(xiàn)
通過上面可知釋放內(nèi)存操作的代碼
在put操作時,上面看到IAllocator有兩個實現(xiàn)類,JNANativeAllocator和UnsafeAllocator兩個實現(xiàn)類,他們有什么區(qū)別?為什么使用JNANativeAllocator?
區(qū)別:UnsafeAllocator對內(nèi)存操作使用的是Unsafe類
為什么使用JNANativeAllocator:Native比Unsafe性能更好,差3倍左右,OHC默認使用JNANativeAllocator;
在日常我們知道通過ByteBuffer#allocateDirect(int capacity)也可以直接申請堆外內(nèi)存,通過ByteBuffer源碼可以看到內(nèi)部使用的就是Unsafe類
可以看到,同時DirectByteBuffer內(nèi)部會調(diào)用 Bits.reserveMemory(size, cap);
Bits.reserveMemory方法中,當內(nèi)存不足時可能會觸發(fā)fullgc,多個申請內(nèi)存的線程同時遇到這種情況時,對于服務(wù)來說是不能接受的,所以這也是OHC自己進行堆外內(nèi)存管理的原因。
如果自己進行實現(xiàn)堆外緩存框架,要考慮上面這種情況。
1.對于OHC的參數(shù)配置、序列化器的選擇,沒有固定的推薦。可以通過壓測逐步調(diào)整到最優(yōu)。
2.由于OHC需要把key和value序列化成字節(jié)數(shù)組存儲到堆外,因此需要選擇合適的序列化工具。
3.在存儲每個鍵值對時,會調(diào)用CacheSerializer#serializedSize計算序列化后的內(nèi)存空間占用,從而申請堆外內(nèi)存。另外,在真正寫入堆外時,會調(diào)用CacheSerializer#serialize真正進行序列化。因此,務(wù)必在這兩個方法中使用相同的序列化方法,防止序列化的大小與計算出來的大小不一致,導致內(nèi)存存不下或者多申請,浪費內(nèi)存空間。
1.當本地緩存影響GC時,可以考慮使用OHC減少本地緩存對GC的影響;
2.區(qū)分熱點數(shù)據(jù),對緩存數(shù)據(jù)進行多級拆分,如堆內(nèi)->堆外->分布式緩存(Reids )等;
3.將較大緩存對象拆分或者按照業(yè)務(wù)維度將不同熱點數(shù)據(jù)緩存到不同介質(zhì)中,減少單一存儲介質(zhì)壓力;
4.減小緩存對象大小,如緩存JSON字符,可對字段名進行縮寫 ,減少存儲數(shù)據(jù)量,降低傳輸數(shù)據(jù)量,同時也能保證數(shù)據(jù)一定的私密性。
對象:{"paramID":1,"paramName":"John Doe"} 正常JSON字符串:{"paramID":1,"paramName":"John Doe"} 壓縮字段名JSON字符串:{"a":1,"b":"John Doe"}
在使用Guava時,存儲25w個緩存對象數(shù)據(jù)占用空間485M
使用OHCache時,儲存60w個緩存對象數(shù)據(jù)占用數(shù)據(jù)387M
為什么存儲空間差別那么多吶?
Guava 存儲的對象是在堆內(nèi)存中的,對象在 JVM 堆中存儲時,它們會占用一定的內(nèi)存空間,并且會包含對象頭、實例數(shù)據(jù)和對齊填充等信息。對象的大小取決于其成員變量的類型和數(shù)量,以及可能存在的對齊需求。同時當對象被頻繁創(chuàng)建和銷毀時,可能會產(chǎn)生內(nèi)存碎片。
而 OHC 它將對象存儲在 JVM 堆外的直接內(nèi)存中。由于堆外內(nèi)存不受 Java 堆內(nèi)存大小限制,OHC 可以更有效地管理和利用內(nèi)存。此外,OHC 底層存儲字節(jié)數(shù)組,存儲字節(jié)數(shù)組相對于直接存儲對象,可以減少對象的創(chuàng)建和銷毀,在一些場景下,直接操作字節(jié)數(shù)組可能比操作對象更高效,因為它避免了對象的額外開銷,如對象頭和引用,減少額外的開銷。同時將對象序列化為二進制數(shù)組存儲,內(nèi)存更加緊湊,減少內(nèi)存碎片的產(chǎn)生。
綜上所述,OHC 在存儲大量對象時能夠更有效地利用內(nèi)存空間,相對于 Guava 在內(nèi)存占用方面具有優(yōu)勢。
另外一個原因,不同序列化框架性能不同,將對象序列化后的占用空間的大小也不同。
name | 默認值 | 描述 |
keySerializer | 需要開發(fā)者實現(xiàn) | Key序列化實現(xiàn) |
valueSerializer | 需要開發(fā)者實現(xiàn) | Value序列化實現(xiàn) |
capacity | 64MB | 緩存容量單位B |
segmentCount | 2倍CPU核心數(shù) | 分段數(shù)量 |
hashTableSize | 8192 | 哈希表的大小 |
loadFactor | 0.75 | 負載因子 |
maxEntrySize | capacity/segmentCount | 緩存項最大字節(jié)限制 |
throwOOME | false | 內(nèi)存不足是否拋出OOM |
hashAlgorighm | MURMUR3 | hash算法,可選性MURMUR3、 CRC32, CRC32C (Jdk9以上支持) |
unlocked | false | 讀寫數(shù)據(jù)是否加鎖,默認是加鎖 |
eviction | LRU | 驅(qū)逐策略,可選項:LRU、W_TINY_LFU、NONE |
frequencySketchSize | hashTableSize數(shù)量 | W_TINY_ LFU frequency sketch 的大小 |
edenSize | 0.2 | W_TINY_LFU 驅(qū)逐策略下使用 |
2.JNI faster than Unsafe?
https://mail.openjdk.org/pipermail/hotspot-dev/2015-February/017089.html
3.OHC源碼
https://github.com/snazy/ohc
4.參考文檔
?序列化框架對比
?Java堆外緩存OHC在馬蜂窩推薦引擎的應(yīng)用
?“堆外緩存”這玩意是真不錯,我要寫進簡歷了。
本文鏈接:http://www.tebozhan.com/showinfo-26-88390-0.html一招MAX降低10倍,現(xiàn)在它是我的了
聲明:本網(wǎng)頁內(nèi)容旨在傳播知識,若有侵權(quán)等問題請及時與本網(wǎng)聯(lián)系,我們將在第一時間刪除處理。郵件:2376512515@qq.com