在應用中,我們使用的 SpringData ES的 ElasticsearchRestTemplate來做查詢,使用方式不對,導致每次ES查詢時都新實例化了一個查詢對象,會載入相關類到元數據中。最終長時間運行後元數據出現記憶體溢出; ...
在應用中,我們使用的 SpringData
ES的 ElasticsearchRestTemplate
來做查詢,使用方式不對,導致每次ES查詢時都新實例化了一個查詢對象,會載入相關類到元數據中。最終長時間運行後元數據出現記憶體溢出;
問題原因:類載入過多,導致元數據OOM。非類實例多或者大對象問題;
排查方式:
查看JVM運行情況,發現元數據滿導致記憶體溢出;
導出記憶體快照,通過OQL快速定位肇事者;
排查對應類的使用場景和載入場景(重點序列化反射場景);
起源
06-15 下午正摩肩擦掌的備戰著晚上8點。收到預發機器的一個GC次數報警。
【警告】UMP JVM監控
【警告】非同步(async採集點:async.jvm.info(別名:jvm監控)15:42:40至15:42:50【xx.xx.xx.xxx(10174422426)(未知分組)】,JVM監控FullGC次數=2次[偏差0%],超過1次FullGC次數>=2次
【時間】2023-06-15 15:42:50
【類型】UMP JVM監控
第一時間詫異了下。該應用主要作用是接MQ消息和定時任務,同時任務和MQ都和線上做了隔離,也沒有收到大流量的告警。
先看了下對應JVM監控:
只看上面都懷疑是監控異常(之前用文件採集的時候有遇到過,看CPU確實有波動。但堆基本無漲幅,懷疑非堆。)
問題排查
定位分析
既然懷疑非堆,我們先通過 jstat
來看看情況
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 0.00 0.89 3.67 97.49 97.96 854 23.720 958 615.300 639.020
0.00 0.00 0.89 3.67 97.49 97.96 854 23.720 958 615.300 639.020
0.00 0.00 0.89 3.67 97.49 97.96 854 23.720 958 615.300 639.020
0.00 0.00 0.89 3.67 97.49 97.96 854 23.720 958 615.300 639.020
0.00 0.00 0.89 3.67 97.49 97.96 854 23.720 958 615.300 639.020
M列代表了metaspace的使用率,當前已經 97.49%
進一步印證了我們的猜測。
接下來通過 jmap
導出記憶體快照分析。這裡我習慣使用 Visual VM
進行分析。
在這裡我們看到有 118588
個類被載入了。正常業務下不會有這麼多類。
這裡我們走了很多彎路。
首先查看記憶體對象,根據類的實例數排了個序,試圖看看是否是某個或某些類實例過多導致。
這裡一般是排查堆異常時使用,可以看大對象和某類的實例數,但我們的問題是類載入過多。非類實例對象多或者大。這裡排除。
後續還嘗試了直接使用 Visual VM
的聚合按包路徑統計,同時排序。收效都甚微。看不出啥異常來。
這裡我們使用 OQL
來進行查詢統計。
語句如下:
var packageClassSizeMap = {};
// 遍歷統計以最後一個逗號做分割
heap.forEachClass(function (it) {
var packageName = it.name.substring(0, it.name.lastIndexOf('.'));
if (packageClassSizeMap[packageName] != null) {
packageClassSizeMap[packageName] = packageClassSizeMap[packageName] + 1;
} else {
packageClassSizeMap[packageName] = 1;
}
});
// 排序 因為Visual VM的查詢有數量限制。
var sortPackageClassSizeMap = [];
map(sort(Object.keys(packageClassSizeMap), function (a, b) {
return packageClassSizeMap[b] - packageClassSizeMap[a]
}), function (it) {
sortPackageClassSizeMap.push({
package: it,
classSize: packageClassSizeMap[it]
})
});
sortPackageClassSizeMap;
執行效果如下:
可以看到,com.jd.bapp.match.sync.query.es.po
下存在 92172
個類。這個包下,不到20個類。這時我們在回到開始查看類的地方。看看該路徑下都是些什麼類。
這裡附帶一提,直接根據路徑獲取對應的類數量:
var packageClassSizeMap = {};
// 遍歷統計以最後一個逗號做分割
heap.forEachClass(function (it) {
var packageName = it.name.substring(0, it.name.lastIndexOf('.'));
// 加路徑過濾版
if (packageName.indexOf('com.jd.bapp.match.sync.query.es.po')){
if (packageClassSizeMap[packageName] != null) {
packageClassSizeMap[packageName] = packageClassSizeMap[packageName] + 1;
} else {
packageClassSizeMap[packageName] = 1;
}
}
});
sortPackageClassSizeMap;
查詢 com.jd.bapp.match.sync.query.es.po
路徑下的classes
我們可以看到:
- 每個ES的Po對象存在大量類載入,在後面有拼接Instantiator_xxxxx
- 部分類有實例,部分類無實例。(count為實例數)
從上面得到的信息得出是ES相關查詢時出現的。我們本地debug查詢跟蹤下。
抽絲剝繭
這裡列下主要排查流程
在應用中,我們使用的 SpringData
ES的 ElasticsearchRestTemplate
來做查詢,主要使用方法 org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate#search
重點代碼如下:
public <T> SearchHits<T> search(Query query, Class<T> clazz, IndexCoordinates index) {
// 初始化request
SearchRequest searchRequest = requestFactory.searchRequest(query, clazz, index);
// 獲取值
SearchResponse response = execute(client -> client.search(searchRequest, RequestOptions.DEFAULT));
SearchDocumentResponseCallback<SearchHits<T>> callback = new ReadSearchDocumentResponseCallback<>(clazz, index);
// 轉換為對應類型
return callback.doWith(SearchDocumentResponse.from(response));
}
載入
首先看初始化request的邏輯
-
org.springframework.data.elasticsearch.core.RequestFactory#searchRequest
-
首先是:
org.springframework.data.elasticsearch.core.RequestFactory#prepareSearchRequest
-
這裡有段代碼是對搜索結果的排序處理:
prepareSort(query, sourceBuilder, getPersistentEntity(clazz));
重點就是這裡的getPersistentEntity(clazz)
這段代碼主要會識別當前類是否已經載入過,沒有載入過則載入到記憶體中:@Nullable private ElasticsearchPersistentEntity<?> getPersistentEntity(@Nullable Class<?> clazz) { // 從convert上下文中獲取判斷該類是否已經載入過,如果沒有載入過,就會重新解析載入並放入上下文 return clazz != null ? elasticsearchConverter.getMappingContext().getPersistentEntity(clazz) : null; }
-
-
具體載入的實現見: 具體實現見:org.springframework.data.mapping.context.AbstractMappingContext#getPersistentEntity(org.springframework.data.util.TypeInformation<?>)
/*
* (non-Javadoc)
* @see org.springframework.data.mapping.model.MappingContext#getPersistentEntity(org.springframework.data.util.TypeInformation)
*/
@Nullable
@Override
public E getPersistentEntity(TypeInformation<?> type) {
Assert.notNull(type, "Type must not be null!");
try {
read.lock();
// 從上下文獲取當前類
Optional<E> entity = persistentEntities.get(type);
// 存在則返回
if (entity != null) {
return entity.orElse(null);
}
} finally {
read.unlock();
}
if (!shouldCreatePersistentEntityFor(type)) {
try {
write.lock();
persistentEntities.put(type, NONE);
} finally {
write.unlock();
}
return null;
}
if (strict) {
throw new MappingException("Unknown persistent entity " + type);
}
// 不存在時,添加該類型到上下文
return addPersistentEntity(type).orElse(null);
}
使用
上述是載入流程。執行查詢後,我們還需要進行一次轉換。這裡就到了使用的地方:org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate#search中 callback.doWith(SearchDocumentResponse.from(response));
這裡這個方法會請求內部的 doWith
方法。實現如下:
@Nullable
public T doWith(@Nullable Document document) {
if (document == null) {
return null;
}
// 獲取到待轉換的類實例
T entity = reader.read(type, document);
return maybeCallbackAfterConvert(entity, document, index);
}
其中的 reader.read
會先從上下文中獲取上述載入到上下文的類信息,然後讀取
@Override
public <R> R read(Class<R> type, Document source) {
TypeInformation<R> typeHint = ClassTypeInformation.from((Class<R>) ClassUtils.getUserClass(type));
typeHint = (TypeInformation<R>) typeMapper.readType(source, typeHint);
if (conversions.hasCustomReadTarget(Map.class, typeHint.getType())) {
R converted = conversionService.convert(source, typeHint.getType());
if (converted == null) {
// EntityReader.read is defined as non nullable , so we cannot return null
throw new ConversionException("conversion service to type " + typeHint.getType().getName() + " returned null");
}
return converted;
}
if (typeHint.isMap() || ClassTypeInformation.OBJECT.equals(typeHint)) {
return (R) source;
}
// 從上下文獲取之前載入的類
ElasticsearchPersistentEntity<?> entity = mappingContext.getRequiredPersistentEntity(typeHint);
// 獲取該類信息
return readEntity(entity, source);
}
讀取會走 org.springframework.data.elasticsearch.core.convert.MappingElasticsearchConverter#readEntity
先是讀取該類的初始化器:EntityInstantiator instantiator = instantiators.getInstantiatorFor(targetEntity);
-
是通過該類實現:
org.springframework.data.convert.KotlinClassGeneratingEntityInstantiator#createInstance
- 然後到:
org.springframework.data.mapping.model.ClassGeneratingEntityInstantiator#doCreateEntityInstantiator
- 然後到:
/*
* (non-Javadoc)
* @see org.springframework.data.convert.ClassGeneratingEntityInstantiator#doCreateEntityInstantiator(org.springframework.data.mapping.PersistentEntity)
*/
@Override
protected EntityInstantiator doCreateEntityInstantiator(PersistentEntity<?, ?> entity) {
PreferredConstructor<?, ?> constructor = entity.getPersistenceConstructor();
if (ReflectionUtils.isSupportedKotlinClass(entity.getType()) && constructor != null) {
PreferredConstructor<?, ?> defaultConstructor = new DefaultingKotlinConstructorResolver(entity)
.getDefaultConstructor();
if (defaultConstructor != null) {
// 獲取對象初始化器
ObjectInstantiator instantiator = createObjectInstantiator(entity, defaultConstructor);
return new DefaultingKotlinClassInstantiatorAdapter(instantiator, constructor);
}
}
return super.doCreateEntityInstantiator(entity);
}
這裡先請求內部的:createObjectInstantiator
/**
* Creates a dynamically generated {@link ObjectInstantiator} for the given {@link PersistentEntity} and
* {@link PreferredConstructor}. There will always be exactly one {@link ObjectInstantiator} instance per
* {@link PersistentEntity}.
*
* @param entity
* @param constructor
* @return
*/
ObjectInstantiator createObjectInstantiator(PersistentEntity<?, ?> entity,
@Nullable PreferredConstructor<?, ?> constructor) {
try {
// 調用生成
return (ObjectInstantiator) this.generator.generateCustomInstantiatorClass(entity, constructor).newInstance();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
獲取對象生成實例:generateCustomInstantiatorClass
這裡獲取類名稱,會追加 _Instantiator_
和對應類的 hashCode
/**
* Generate a new class for the given {@link PersistentEntity}.
*
* @param entity
* @param constructor
* @return
*/
public Class<?> generateCustomInstantiatorClass(PersistentEntity<?, ?> entity,
@Nullable PreferredConstructor<?, ?> constructor) {
// 獲取類名稱
String className = generateClassName(entity);
byte[] bytecode = generateBytecode(className, entity, constructor);
Class<?> type = entity.getType();
try {
return ReflectUtils.defineClass(className, bytecode, type.getClassLoader(), type.getProtectionDomain(), type);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
private static final String TAG = "_Instantiator_";
/**
* @param entity
* @return
*/
private String generateClassName(PersistentEntity<?, ?> entity) {
// 類名+TAG+hashCode
return entity.getType().getName() + TAG + Integer.toString(entity.hashCode(), 36);
}
到此我們元數據中的一堆 拼接了 Instantiator_xxxxx
的類來源就破案了。
真相大白
對應問題產生的問題也很簡單。
// 每次search前 都new了個RestTemplate,導致上下文發生變化,每次重新生成載入
new ElasticsearchRestTemplate(cluster);
這裡我們是雙集群模式,每次請求時會由負載決定使用那一個集群。之前在這裡每次都 new
了一個待使用集群的實例。
內部的上下文每次初始化後都是空的。
-
請求查詢ES
-
初始化ES查詢
- 上下文為空
- 載入類信息(hashCode發生變化)
- 獲取類信息(重計算類名)
- 重新載入類到元數據
-
最終長時間運行後元數據空間溢出;
事後結論
1.當時的臨時方案是重啟應用,元數據區清空,同時臨時也可以放大元數據區大小。
2.元數據區的類型卸載或回收,8以後已經不使用了。
3.元數據區的泄漏排查思路:找到載入多的類,然後排查使用情況和可能的載入場景,一般在各種序列化反射場景。
4.快速排查可使用我們的方案。使用OQL來完成。
5.監控可以考慮載入類實例監控和元數據空間使用大小監控和對應報警。可以提前發現和處理。
6.ES查詢在啟動時對應集群內部初始化一個查詢實例。使用那個集群就使用對應的集群查詢實例。
附錄
VisualVM下載地址:https://visualvm.github.io/
OQL: Object Query Language 可參看在VisualVM中使用OQL分析
獲取路徑下類載入數量,從高到低排序
var packageClassSizeMap = {};
// 遍歷統計以最後一個逗號做分割
heap.forEachClass(function (it) {
var packageName = it.name.substring(0, it.name.lastIndexOf('.'));
if (packageClassSizeMap[packageName] != null) {
packageClassSizeMap[packageName] = packageClassSizeMap[packageName] + 1;
} else {
packageClassSizeMap[packageName] = 1;
}
});
// 排序 因為Visual VM的查詢有數量限制。
var sortPackageClassSizeMap = [];
map(sort(Object.keys(packageClassSizeMap), function (a, b) {
return packageClassSizeMap[b] - packageClassSizeMap[a]
}), function (it) {
sortPackageClassSizeMap.push({
package: it,
classSize: packageClassSizeMap[it]
})
});
sortPackageClassSizeMap;
獲取某個路徑下類載入數量
var packageClassSizeMap = {};
// 遍歷統計以最後一個逗號做分割
heap.forEachClass(function (it) {
var packageName = it.name.substring(0, it.name.lastIndexOf('.'));
// 加路徑過濾版
if (packageName.indexOf('com.jd.bapp.match.sync.query.es.po')){
if (packageClassSizeMap[packageName] != null) {
packageClassSizeMap[packageName] = packageClassSizeMap[packageName] + 1;
} else {
packageClassSizeMap[packageName] = 1;
}
}
});
sortPackageClassSizeMap;
特別鳴謝
感謝黃仕清和Jdos同學提供的技術支持。
作者:京東零售 王建波
來源:京東雲開發者社區