記錄一次首頁優(yōu)化的經(jīng)歷
公司最近在進行多品牌合一,原來五個品牌的app要合并為一個。品牌立項、審批、方案確定,歷史數(shù)據(jù)遷移、前期的基礎(chǔ)工程搭建,兼容以及涉及三方的交互以及改造,需求梳理等也都基本完成,原來計劃9月中旬進行上線,但是上線后服務(wù)端的壓測一直通不過-首頁抗不過太高的并發(fā)。
app首頁里面是一個信息流,里面包含運營以及用戶發(fā)布的各種視頻、動態(tài)以及問答之類的帖子,廣告位、運營后臺配置的推薦帖以及會在信息流指定位置放置一些banner位等,還包括智能推薦、三方推薦以及一部分自研的推薦數(shù)據(jù)(服務(wù)端有開關(guān)配置走那種推薦系統(tǒng)),服務(wù)端這塊統(tǒng)一由一個接口組裝各種數(shù)據(jù)后返回給app端。組裝的數(shù)據(jù)大部分是存在redis緩存里面,比如廣告位、banner位以及一些運營自己在后臺配置的一些配置,這些因為變化不大或者基本不怎么變化,從緩存讀取耗時也都很快的,耗時比較慢的點主要集中在 內(nèi)部的feign調(diào)用以及查詢es信息流時候要進行組裝信息,這些在壓測時候基本占用了整個應(yīng)用耗時的90%以上。使用hutool的StopWatch統(tǒng)計耗時大概如下這種:(單位 ms)
000000201 08% 組裝es數(shù)據(jù)
000000599 25% feign調(diào)用uc獲取黑名單數(shù)據(jù)
000000495 21% feign調(diào)用社區(qū)查詢置頂數(shù)據(jù)
000000603 25% 查詢帖子列表
000000394 16% 修改意向車型
000000002 00% 獲取內(nèi)容標簽數(shù)據(jù)
000000000 00% 獲取熱門話題數(shù)據(jù)
000000002 00% 獲取專題組件數(shù)據(jù)
000000090 04% 獲取信息流廣告位數(shù)據(jù)
000000001 00% 獲取快速入口數(shù)據(jù)
000000002 00% 獲取輪播廣告數(shù)據(jù)
在單臺機器 2核4G,壓測100并發(fā)的情況下,平均耗時能達到2s左右,p90 能在3s多,整體性能很差。
在壓測時候,使用arthas的監(jiān)控工具,在耗時比較嚴重的地方進行監(jiān)控,首先是feign調(diào)用的地方,發(fā)現(xiàn)有個奇怪的線程,使用trace命令 監(jiān)控超過30ms的請求,發(fā)現(xiàn)很少
trace com.gwm.marketing.service.impl.HomeRecommendServiceImpl addSuperStreams '#cost > 30' -n 50 --skipJDKMethod false
但是在調(diào)用方的耗時 能達到驚人的1s以上,如果是并發(fā)量比較少的情況,比如50并發(fā)左右,這個耗時不超過200ms,但是一旦并發(fā)過高立馬耗時就上來了。服務(wù)端內(nèi)部是通過feign調(diào)用來進行rpc調(diào)用的,而且被調(diào)用方的耗時也很快,初步懷疑是 不是 調(diào)用方 存在gc的問題,或者是調(diào)用方 線程池阻塞 以及 feign調(diào)用在高并發(fā)下的默認的鏈接存在阻塞導(dǎo)致的。另外還有發(fā)現(xiàn)es以及部分日志也陸續(xù)存在耗時比較久的情況,總體上這些加起來 ,導(dǎo)致整個耗時比較嚴重。
首先看了下服務(wù)器的gc,單機2核4g 配置的gc信息如下:
-Djava.net.preferIPv4Stack=true -Djava.util.Arrays.useLegacyMergeSort=true -Dfile.encoding=UTF-8 -server -Xms512m -Xmx512m -XX:NewSize=128m -XX:MaxNewSize=128m -Xss256k -Drocketmq.client.logUseSlf4j=true -XX:+UseG1GC -XX:+PrintGCTimeStamps -Xloggc:log/gc-%t.log -XX:+UseGCLogFileRotation -XX:GCLogFileSize=10M
這個配置還使用的是G1,首頁的這個場景,首頁是服務(wù)器的內(nèi)存過小,使用g1的話,對于我們想要的是吞吐量更大的其實有些差距,我們的目的是為了吞吐量更大,響應(yīng)時間更快,那么使用cms的方式其實更好。然后讓運維改了一下 gc的回收方式為cms:
-Djava.net.preferIPv4Stack=true -Djava.util.Arrays.useLegacyMergeSort=true -Dfile.encoding=UTF-8 -server -Xms512m -Xmx512m -XX:NewSize=128m -XX:MaxNewSize=128m -Xss256k -Drocketmq.client.logUseSlf4j=true -XX:+UseConcMarkSweepGC -XX:+CMSIncrementalMode -XX:CMSInitiatingOccupancyFraction=75 -Xloggc:log/gc-%t.log -XX:+UseGCLogFileRotation -XX:GCLogFileSize=10M
實測后發(fā)現(xiàn)有一定的提升,比如使用g1的話,壓測5分鐘的耗時 和使用cms的壓測耗時
這個是能提升一定的性能,然后耗時比較嚴重的第二個部分 就是 feign調(diào)用- 為何單個接口的耗時很快,但是在feign調(diào)用時候就很慢?
首先feign調(diào)用 我們的配置是走了內(nèi)網(wǎng)的,即不走網(wǎng)關(guān)-
我們的項目是基于nepxion里面的openfeign,openfeign使用的是默認的HttpURLConnection的鏈接方式,而且看了我們的openfeign的版本是 2.2.3的,這個版本還沒有池化的概念-就是每次的rpc通過
feign調(diào)用的話,每次都需要走tpc的三次握手和四次揮手, 這個耗時在請求量比較少的情況下體現(xiàn)不出來,但是在并發(fā)量比較高的情況下,就體現(xiàn)出來了- 表現(xiàn)就是 明明在被調(diào)用方的耗時很小,但是在調(diào)用方顯示出來的耗時就是很長,這個通過arthas和skywalking 的監(jiān)控都證明了這一點。skywalking顯示的 self耗時很長但是業(yè)務(wù)耗時很快,arthas在容器內(nèi)看好是情況也是如此,因此需要對feign調(diào)用進行改造。
首先是對open-feign的版本進行升級,openfeign支持httpclient以及okhttpclient ,在最新的 4.2.0-SNAPSHOT 上 OkHttpFeignLoadBalancedConfiguration 里面已經(jīng)是使用了連接池了,源碼如下:
@Configuration(proxyBeanMethods = false) @ConditionalOnClass(OkHttpClient.class) @ConditionalOnProperty("spring.cloud.openfeign.okhttp.enabled") @ConditionalOnBean({ LoadBalancerClient.class, LoadBalancerClientFactory.class }) @EnableConfigurationProperties(LoadBalancerClientsProperties.class) class OkHttpFeignLoadBalancerConfiguration { @Bean @ConditionalOnMissingBean @Conditional(OnRetryNotEnabledCondition.class) public Client feignClient(okhttp3.OkHttpClient okHttpClient, LoadBalancerClient loadBalancerClient, LoadBalancerClientFactory loadBalancerClientFactory, List<LoadBalancerFeignRequestTransformer> transformers) { OkHttpClient delegate = new OkHttpClient(okHttpClient); return new FeignBlockingLoadBalancerClient(delegate, loadBalancerClient, loadBalancerClientFactory, transformers); } @Bean @ConditionalOnMissingBean @ConditionalOnClass(name = "org.springframework.retry.support.RetryTemplate") @ConditionalOnBean(LoadBalancedRetryFactory.class) @ConditionalOnProperty(value = "spring.cloud.loadbalancer.retry.enabled", havingValue = "true", matchIfMissing = true) public Client feignRetryClient(LoadBalancerClient loadBalancerClient, okhttp3.OkHttpClient okHttpClient, LoadBalancedRetryFactory loadBalancedRetryFactory, LoadBalancerClientFactory loadBalancerClientFactory, List<LoadBalancerFeignRequestTransformer> transformers) { OkHttpClient delegate = new OkHttpClient(okHttpClient); return new RetryableFeignBlockingLoadBalancerClient(delegate, loadBalancerClient, loadBalancedRetryFactory, loadBalancerClientFactory, transformers); } }
而我們這個2.2.3的還不支持池化,故首先把open-feign的版本升級了,以支持可以池化,另外就是需要配置 池化的參數(shù)以及開啟okhttpclient
feign.httpclient.enabled = false #httpClient 關(guān)閉
feign.okhttp.enabled = true #okHttpClient 啟用
feign.httpclient.maxConnections = 1000 #最大連接數(shù)
feign.httpclient.maxConnectionsPerRoute = 300 #feign單個路徑的最大連接數(shù)
feign.httpclient.connectionTimeout = 3000 #超時時間
try { Request.Options options = new Request.Options(connectionTimeoutConfig.getFeignConnectionTimeout(),connectionTimeoutConfig.getFeignReadTimeout()); data = gwmInterFeignClient.queryLikeAndCollectStatusByThreadsOptionLimit(appUserThreadStatusQuery,options).getData(); } catch (Exception e) { log.error("===feign調(diào)用超時====請求廢棄"); data = new AppUserThreadFDto(); }
再就是在arthas下面發(fā)現(xiàn)有日志也存在耗時嚴重的情況,對于非必要的日志,進行不去進行打印 或者修改日志級別為debug,當然還有一種方式 就是把寫日志的方式 改成異步來寫。
另外還發(fā)現(xiàn)代碼里面如果判斷一個對象是否在集合里,也會存在性能耗時的問題,這個需要改為并發(fā)執(zhí)行 ,類似代碼如下:
優(yōu)化前:
streams.forEach(stream -> { stream.setIsLike(CollectionUtils.isNotEmpty(data.getLikeThreads()) ? (data.getLikeThreads().contains(stream.getThreadId()) ? ONE : ZERO) : ZERO); stream.setIsCollect(CollectionUtils.isNotEmpty(data.getCollectThreads()) ? (data.getCollectThreads().contains(stream.getThreadId()) ? ONE : ZERO) : ZERO);
優(yōu)化后:
streams.forEach(stream -> { stream.setIsLike(CollectionUtils.isNotEmpty(finalData.getLikeThreads())? (finalData.getLikeThreads().parallelStream().anyMatch(d->d.equals(stream.getThreadId()))? ONE : ZERO) : ZERO); stream.setIsCollect(CollectionUtils.isNotEmpty(finalData.getCollectThreads()) ? (finalData.getCollectThreads().parallelStream().anyMatch(d->d.equals(stream.getThreadId())) ? ONE : ZERO) : ZERO); });
除了這個還有es的耗時嚴重的的問題,我們的es 采用的是5.7的版本,比較老了,es主要是組裝數(shù)據(jù) 以及查詢相對比較方便,庫表大概有幾千萬的帖子數(shù)據(jù),但是每次耗時 有時候也能達到400-500ms的延遲。
這個暫時沒找到原因,后面有時間了在繼續(xù)。
還有就是在首頁里面加上sentinel 限流,首頁里面有一部分耗時比較長的部分,比如 調(diào)用三方的接口,比如推薦部分,這部分三方接口本身就有一些性能壓力,我們這邊在調(diào)用時候 是使用的http來進行調(diào)用的,超時時間是3s,實測發(fā)現(xiàn),當并發(fā)達到10 到20的時候,這部分基本就3s超時了。一部分是他們的推薦規(guī)則確實挺復(fù)雜的,推薦的數(shù)據(jù)要進行達標,要根據(jù)用戶的個個業(yè)務(wù)維度去進行篩選,雖然數(shù)據(jù)已經(jīng)提前寫入緩存了,調(diào)用里面業(yè)務(wù)有些復(fù)雜 導(dǎo)致耗時比較嚴重,特別是并發(fā)稍微高的情況下。于是在調(diào)用他們這些推薦數(shù)據(jù)的地方 加了sentinel 的限流。
加sentinel限流的時候,一般有兩種方式,一種是針對指定的資源,比如接口、指定的bean等進行限流和降級,還有一種是sentinel + openfeign 來進行限流和降級。兩種方式的效果是類似的,只是配置略微有一些區(qū)別,另外第一種方式是有限制的,即限制的資源需要是spring 容器里面的bean對象,否則限流和降級不會生效。我使用的是第一種方式,代碼如下
@Override @SentinelResource(value = "queryRecommend", fallback = "getStaticData",blockHandler = "blockExceptionHandler") public List<String> queryRecommend(HomeRecommendStreamQuery query, HomeRecommendStreamDto streamDto, String sourceApp) { //用戶身份 StopWatch st = StopWatch.create("內(nèi)容中臺推薦數(shù)據(jù)"); String userType = this.getUserType(query.getIdentity(), query.getSource(), query.getUserId()); //獲取當前有用戶是否推薦用戶信息 boolean isRecommend = true; if (StringUtils.isNotEmpty(query.getUserId())) { Set<String> userClosed = gwmRedisTemplate.opsForValue().get(USER_CLOSED_RECOMMEND); if (CollectionUtils.isNotEmpty(userClosed)) { boolean anyMatch = userClosed.stream().anyMatch(str -> str.equals(query.getUserId())); if (anyMatch) { isRecommend = false; } } } //構(gòu)建推薦系統(tǒng)對象信息 RecommendQuery build = RecommendQuery.builder() .userType(userType) //推薦系統(tǒng)需要唯一標識,如果未登錄用戶傳設(shè)備id,登錄用戶傳userID .userId(StringUtils.isNotEmpty(query.getUserId()) ? query.getUserId() : query.getDeviceId()) .intendedBrandList(query.getIntendedBrandList()) .intendedVehicleList(query.getIntendedVehicleList()) .recommendationSwitch(isRecommend) .strategyType(query.getStrategyType()) .deviceId(query.getDeviceId()) .page(query.getPage()) .pageSize(query.getSize()) .build(); st.start("推薦調(diào)用uc黑名單接口"); if (StringUtils.isNotEmpty(query.getUserId())) { CmsAppUserIdQuery qry = new CmsAppUserIdQuery(); qry.setUserId(query.getUserId()); List<String> list = feignUserCenterClient.getBlackUserIds(qry, sourceApp).getData(); if(query.isCheckBlack()){ build.setBlacklistUsers(list); } query.setBlacklistUsers(list); } st.stop(); // 調(diào)用內(nèi)容中臺推薦接口 st.start("調(diào)用內(nèi)容中臺推薦接口"); String recommend = okhttp3Util.postBody(config.getUrl(), build); st.stop(); logger.info("首頁推薦查詢推薦系統(tǒng)入?yún)ⅲ簕},推薦系統(tǒng)返回:{} , ssoId:{}", JSONObject.toJSONString(build), recommend, query.getUserId()); RecommendResponse<RecommendStrategyDto> response = parseBody(recommend, RecommendStrategyDto.class); List<String> idList = null; if (Objects.nonNull(response)) { if (response.isSuccessful()) { RecommendStrategyDto recommendStrategyDto = response.getData(); streamDto.setStrategyType(recommendStrategyDto.getStrategyType()); idList = recommendStrategyDto.getContentIds(); } else if (!response.recommendChannelNormal()) { streamDto.setRecommendChannelAvailable(false); return new ArrayList<>(); } } //查詢推薦系統(tǒng)數(shù)據(jù)為空 if (CollectionUtils.isEmpty(idList) && query.getPage() == 1) { //nacos兜底,取默認配置數(shù)據(jù) idList = Optional.ofNullable(config.getPostIds()).orElse(Collections.emptyList()); } return idList; }
public List<String> getStaticData(HomeRecommendStreamQuery query, HomeRecommendStreamDto streamDto, String sourceApp) { logger.info("==========start sentinel 降級策略=========="); try { Random random = new Random(); int r = random.nextInt(100) + 1; logger.debug("========隨機從緩存獲取一組帖子id:" + r); Set<String> stringSet = gwmRedisTemplate.opsForValue().get(RedisConstants.THREAD_HOT_SCORE_KEY + r); logger.debug("=========隨機從緩存獲取帖子result=======" + JSONObject.toJSON(stringSet)); if (CollectionUtils.isNotEmpty(stringSet)) { List<String> stringList = new ArrayList<>(stringSet); return stringList; } else { logger.debug("============讀取默認配置數(shù)據(jù)========" + config.getPostIds()); return Optional.ofNullable(config.getPostIds()).orElse(Collections.emptyList()); } } catch (Exception e) { throw new RuntimeException(e); } } /** * blockHandler需要設(shè)置為static * * @param ex * @return */ public List<String> blockExceptionHandler(HomeRecommendStreamQuery query, HomeRecommendStreamDto streamDto, String sourceApp,BlockException ex) { try { Random random = new Random(); int r = random.nextInt(100) + 1; logger.debug("========隨機從緩存獲取一組帖子id:" + r); Set<String> stringSet = gwmRedisTemplate.opsForValue().get(RedisConstants.THREAD_HOT_SCORE_KEY + r); logger.debug("=========隨機從緩存獲取帖子result=======" + JSONObject.toJSON(stringSet)); if (CollectionUtils.isNotEmpty(stringSet)) { List<String> stringList = new ArrayList<>(stringSet); return stringList; } else { logger.debug("============讀取默認配置數(shù)據(jù)========" + config.getPostIds()); return Optional.ofNullable(config.getPostIds()).orElse(Collections.emptyList()); } } catch (Exception e) { throw new RuntimeException(e); } }
總結(jié)起來就是:針對首頁的性能問題,優(yōu)化了gc的方式,feign調(diào)用的改造,部分代碼改為并行、日志優(yōu)化 、es的查詢優(yōu)化以及使用sentinel做限流降級。 當然還有就是,我們這個首頁這么多的內(nèi)容都寫到了一個接口里面,對于服務(wù)端的性能確實壓力很大,后面看能不能進行拆分,讓端上進行單個接口的請求,由一個接口變?yōu)槎鄠€接口,這樣也能減少不少服務(wù)端的壓力。最后優(yōu)化后的壓測結(jié)果:
目前可優(yōu)化的部分主要是集中在es這部分上面了。

浙公網(wǎng)安備 33010602011771號