一次後設資料空間記憶體溢位的排查記錄

2023-07-11 12:00:11

在應用中,我們使用的 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同學提供的技術支援。

作者:京東零售 王建波

來源:京東雲開發者社群