本文主要總結了在ICBU的核心溝通場景下服務端在此次效能最佳化過程中做的工作,供大家參考討論。
一、背景與效果
ICBU的核心溝通場景有了10年的“積累”,核心場景的介面響應耗時被拉的越來越長,也讓效能最佳化工作提上了日程,先說結論,經過這一波前後端齊心協力的最佳化努力,兩個核心介面90分位的資料,FCP平均由2.6s下降到1.9s,LCP平均由2.8s下降到2s。本文主要着眼於服務端在此次效能最佳化過程中做的工作,供大家參考討論。
二、措施一:流式分塊傳輸(核心)
2.1. HTTP分塊傳輸介紹
分塊傳輸編碼(Chunked Transfer Encoding)是一種HTTP/1.1協議中的數據傳輸機制,它允許伺服器在不知道整個內容大小的情況下,就開始傳輸動態生成的內容。這種機制特別適用於生成大量資料或者由於某種原因資料大小未知的情況。
在分塊傳輸編碼中,資料被分為一系列的“塊”(chunk)。每一個塊都包括一個長度標識(以十六進制格式表示)和緊隨其後的資料本身,然後是一個CRLF(即"\r\n",代表回車和換行)來結束這個塊。塊的長度標識會告訴接收方這個塊的資料部分有多長,使得接收方可以知道何時結束這一塊並準備好讀取下一塊。
當所有資料都發送完畢時,伺服器會發送一個長度為零的塊,表明資料已經全部發送完畢。零長度塊後面可能會跟隨一些附加的頭部資訊(尾部頭部),然後再用一個CRLF來結束整個訊息體。
我們可以藉助分塊傳輸協議完成對切分好的vm進行分塊推送,從而達到整體HTML介面流式渲染的效果,在實現時,只需要對HTTP的header進行改造即可:
public void chunked(HttpServletRequest request, HttpServletResponse response) { try (PrintWriter writer = response.getWriter()) { // 設定響應型別和編碼 oriResponse.setContentType(MediaType.TEXT_HTML_VALUE + ";charset=UTF-8"); oriResponse.setHeader("Transfer-Encoding", "chunked"); oriResponse.addHeader("X-Accel-Buffering", "no"); // 第一段 Context modelMain = getmessengerMainContext(request, response, aliId); flushVm("/velocity/layout/Main.vm", modelMain, writer); // 第二段 Context modelSec = getmessengerSecondContext(request, response, aliId, user); flushVm("/velocity/layout/Second.vm", modelSec, writer); // 第三段 Context modelThird = getmessengerThirdContext(request, response, user); flushVm("/velocity/layout/Third.vm", modelThird, writer); } catch (Exception e) { // logger } } private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception { StringWriter tmpWri = new StringWriter(); // vm渲染 engine.mergeTemplate(templateName, "UTF-8", model, tmpWri); // 資料寫出 writer.write(tmpWri.toString()); writer.flush(); }
2.2. 頁面流式分塊傳輸最佳化方案
我們現在的大部分應用都是springmvc架構,瀏覽器發起請求,後端伺服器進行資料準備與vm渲染,之後返回html給瀏覽器。
從請求到達服務端開始計算,一次HTML請求到頁面載入完全要經過網路請求、網路傳輸與前端資源渲染三個階段:
HTML流式輸出,思路是對HTML介面進行拆分,之後由伺服器分批進行推送,這樣做有兩個好處:
服務端分批進行資料準備,可以減少首次需要準備的資料量,極大縮短準備時間。
瀏覽器分批接收資料,當接收到第一部分的資料時,可以立刻進行js渲染,提升其利用率。
這個思路對需要載入資源較多的頁面有很明顯的效果,在我們此次的介面最佳化中,頁面的FCP與LCP均有300ms-400ms的效能提升,在進行vm介面的資料拆分時,有以下幾個技巧:
注意介面資源載入的依賴關係,前序介面不能依賴後序介面的變數。
將偏靜態與核心的資源前置,後端伺服器可以快速完成資料準備並返回第一段html供前端載入。
2.3. 注意事項
此次最佳化的應用與介面本身歷史包袱很重,在進行流式改造的過程中,我們遇到了不少的阻力與挑戰,在解決問題的過程也學到了很多東西,這部分主要對遇到的問題進行整理。
二方包或自定義的HTTP請求 filter 會改寫 response 的 header,導致分塊傳輸失效。如果應用中有這種情況,我們在進行流式推送時,可以獲取到最原始的response,防止被其他filter影響:
/** * 防止filter或者其他代理包裝了response並開啟快取 * 這裏獲取到真實的response * * @param response * @return */ private static HttpServletResponse getResponse(HttpServletResponse response) { ServletResponse resp = response; while (resp instanceof ServletResponseWrapper) { ServletResponseWrapper responseWrapper = (ServletResponseWrapper) resp; resp = responseWrapper.getResponse(); } return (HttpServletResponse) resp; }
谷歌瀏覽器禁止跨域名寫入cookie,我們的應用介面會以iframe的形式嵌入其他介面,谷歌瀏覽器正在逐步禁止跨域名寫cookie,如下所示:
爲了確保cookie能正常寫入,需要指定cookie的SameSite=None。
VelocityEngine模板引擎的自定義tool。
我們的專案中使用的模板引擎為VelocityEngine,在流式分塊傳輸時,需要手動渲染vm:
private void flushVm(String templateName, Context model, PrintWriter writer) throws Exception { StringWriter tmpWri = new StringWriter(); // vm渲染 engine.mergeTemplate(templateName, "UTF-8", model, tmpWri); // 資料寫出 writer.write(tmpWri.toString()); writer.flush(); }
需要注意的是VelocityEngine模板引擎支援自定義tool,在vm檔案中是如下的形式,當vm引擎渲染到對應位置時,會呼叫配置好的方法進行解析:
<title>$tool.do("xx", "$!{arg}")</title>
如果用註解的形式進行vm渲染,框架本身會幫我們自動做tools的初始化。但如果我們想手動渲染vm,那麼需要將這些tools初始化到context中:
/** * 初始化 toolbox.xml 中的工具 */ private Context initContext(HttpServletRequest request, HttpServletResponse response) { ViewToolContext viewToolContext = null; try { ServletContext servletContext = request.getServletContext(); viewToolContext = new ViewToolContext(engine, request, response, servletContext); VelocityToolsRepository velocityToolsRepository = VelocityToolsRepository.get(servletContext); if (velocityToolsRepository != null) { viewToolContext.putAll(velocityToolsRepository.getTools()); } } catch (Exception e) { LOGGER.error("createVelocityContext error", e); return null; } }
對於比較古老的應用,VelocityToolsRepository需要將二方包版本進行升級,而且需要注意,velocity-spring-boot-starter升級後可能存在tool.xml檔案失效的問題,建議可以採用註解的形式實現tool,並且注意tool對應java類的路徑。
@DefaultKey("assetsVersion") public class AssertsVersionTool extends SafeConfig { public String get(String key) { return AssetsVersionUtil.get(key); } }
Nginx 的 location 配置
server { location ~ ^/chunked { add_header X-Accel-Buffering no; proxy_http_version 1.1; proxy_cache off; # 關閉快取 proxy_buffering off; # 關閉代理緩衝 chunked_transfer_encoding on; # 開啟分塊傳輸編碼 proxy_pass http://backends; } }
ngnix配置本身可能存在對流式輸出的不相容,這個問題是很難列舉的,我們遇到的問題是如下配置,需要將SC_Enabled關閉。
SC_Enabled on; SC_AppName gangesweb; SC_OldDomains //b.alicdn.com; SC_NewDomains //b.alicdn.com; SC_OldDomains //bg.alicdn.com; SC_NewDomains //bg.alicdn.com; SC_FilterCntType text/html; SC_AsyncVariableNames asyncResource; SC_MaxUrlLen 1024;
詳見:https://github.com/dinic/styleCombine3
ngnix緩衝區大小,在我們最佳化的過程中,某個應用並沒有指定緩衝區大小,取的預設值,我們的改造導致http請求的header變大了,導致報錯upstream sent too big header while reading response header from upstream
proxy_buffers 128 32k; proxy_buffer_size 64k; proxy_busy_buffers_size 128k; client_header_buffer_size 32k; large_client_header_buffers 4 16k;
如果頁面在瀏覽器上有問題時,可以透過curl命令在伺服器上直接訪問,排查是否為ngnix的問題:
curl --trace - 'http://127.0.0.1:7001/chunked' \ -H 'cookie: xxx'
ThreadLocal與StreamingResponseBody
在開始,我們使用StreamingResponseBody來實現的分塊傳輸:
@GetMapping("/chunked") public ResponseEntity<StreamingResponseBody> streamChunkedData() { StreamingResponseBody stream = outputStream -> { // 第一段 Context modelMain = getmessengerMainContext(request, response, aliId); flushVm("/velocity/layout/Main.vm", modelMain, writer); // 第二段 Context modelSec = getmessengerSecondContext(request, response, aliId, user); flushVm("/velocity/layout/Second.vm", modelSec, writer); // 第三段 Context modelThird = getmessengerThirdContext(request, response, user); flushVm("/velocity/layout/Third.vm", modelThird, writer); } }; return ResponseEntity.ok() .contentType(MediaType.TEXT_HTML) .body(stream); } }
但是我們在執行時發現vm的部分變數會渲染失敗,卡點了不少時間,後面在排查過程中發現應用在處理http請求時會在ThreadLocal中進行用戶數據、request資料與部分上下文的儲存,而後續vm資料準備時,有一部分資料是直接從中讀取或者間接依賴的,而StreamingResponseBody本身是非同步的(可以看如下的程式碼註釋),這就導致新開闢的執行緒讀不到原執行緒ThreadLocal的資料,進而渲染錯誤:
/** * A controller method return value type for asynchronous request processing * where the application can write directly to the response {@code OutputStream} * without holding up the Servlet container thread. * * <p><strong>Note:</strong> when using this option it is highly recommended to * configure explicitly the TaskExecutor used in Spring MVC for executing * asynchronous requests. Both the MVC Java config and the MVC namespaces provide * options to configure asynchronous handling. If not using those, an application * can set the {@code taskExecutor} property of * {@link org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter * RequestMappingHandlerAdapter}. * * @author Rossen Stoyanchev * @since 4.2 */ @FunctionalInterface public interface StreamingResponseBody { /** * A callback for writing to the response body. * @param outputStream the stream for the response body * @throws IOException an exception while writing */ void writeTo(OutputStream outputStream) throws IOException; }
三、措施二:非流量中介軟體最佳化
在效能最佳化過程中,我們發現在流量高峰期,某個服務介面的平均耗時會顯著升高,結合arths分析發現,是由於在流量高峰期,對於配置中心的呼叫被限流了。原因是配置中心的使用不規範,每次都是呼叫getConfig方法從配置中心服務端拉取的資料。
在讀取配置中心的配置時,更標準的使用方法是由配置中心主動推送變更,客戶端監聽配置資訊快取到本地,這樣,每次讀取配置其實讀取的是機器的本地快取,可以參考如下的方式:
public static void registerDynamicConfig(final String dataIdKey, final String groupName) { IOException initError = null; try { String e = Diamond.getConfig(dataIdKey, groupName, DEFAULT_TIME_OUT); if(e != null) { getGroup(groupName).put(dataIdKey, e); } logger.info("Diamond config init: dataId=" + dataIdKey + ", groupName=" + groupName + "; initValue=" + e); } catch (IOException e) { logger.error("Diamond config init error: dataId=" + dataIdKey, e); initError = e; } Diamond.addListener(dataIdKey, groupName, new ManagerListener() { @Override public Executor getExecutor() { return null; } @Override public void receiveConfigInfo(String s) { String oldValue = (String)DynamicConfig.getGroup(groupName).get(dataIdKey); DynamicConfig.getGroup(groupName).put(dataIdKey, s); DynamicConfig.logger.warn( "Receive config update: dataId=" + dataIdKey + ", newValue=" + s + ", oldValue=" + oldValue); } }); if(initError != null) { throw new RuntimeException("Diamond config init error: dataId=" + dataIdKey, initError); } }
四、措施三:資料直出
靜態圖片直出,頁面上有靜態的loge圖片,原本為cdn地址,在瀏覽器渲染時,需要建聯並會搶佔執行緒,對於這類不會發生髮生變化的圖片,可以直接替換為base64的形式,js可以直接載入。
載入資料直出,這部分需要根據具體業務來分析,部分業務資料是瀏覽器執行js指令碼在本地二次請求載入的,由於低端機以及本地瀏覽器的能力限制,如果需要載入的資料很多,就很導致js執行緒的擠佔,拖慢整體的時間,因此,可以考慮在伺服器將部分資料預先載入好,隨http請求一起給瀏覽器,減少這部分的卡點。
資料直出有利有弊,對於頁面的載入效能有正向影響的同時,也會同時導致HTTP的response增大以及服務端RT的升高。資料直出與流式分塊傳輸相結合的效果可能會更好,當服務端分塊響應HTTP請求時,本身的response就被切割成多塊,單次大小得到了控制,流式分塊傳輸下,服務端分批執行資料準備的策略也能很好的緩衝RT增長的問題。
五、措施四:本地快取
以我們遇到的一個問題為例,我們的雲盤檔案列表需要在後端準備好檔案所屬人的暱稱,這是在後端伺服器由使用者id呼叫會員的rpc介面實時查詢的。分析這個場景,我們不難發現,同一時間,IM場景下的檔案所屬人往往是其中歸屬在聊天的幾個人名下的,因此,可以利用HashMap作為快取rpc查詢到的會員暱稱,避免重複的查詢與呼叫。
六、措施五:下線歷史債務
針對有歷史包袱的應用,歷史債務導致的額外耗時往往很大,這些歷史程式碼可能包括以下幾類:
未下線的實驗或者分流介面呼叫;
時間線拉長,這部分的程式碼殘骸在所難免,而且積少成多,累計起來往往有幾十上百毫秒的資源浪費,再加上業務開發時,大家往往沒有額外資源去評估這部分的很多程式碼是否可以下線,因此可以藉助效能最佳化的契機進行治理。
已經廢棄的vm變數與重複變數治理。
對vm變數的盤點過程中發現有很多之前在使用但現在已經廢棄的變數。當然,這部分變數的需要前後端同學共同梳理,防止下線線上依舊依賴的變數。
來源:https://mp.weixin.qq.com/s/06eND-fUGQ7Y6gwJxmvwQQ