擊上方 "程序員小樂"關注, 星標或置頂一起成長
近,我們把 Universe.com 的主頁性能提高了 10 幾倍。讓我們一起來探索一下我們是如何實現這個結果的,涉及到了哪些技術。
一開始,我們先來看看,為什么網站性能如此重要(在本文末尾附有本案例研究的鏈接):
在本文中,我們將簡要介紹幫助我們提高頁面性能的以下幾個主要方面:
針對某些情況,我們的主頁是用 React(TypeScript)、Phoenix(Elixir)、Puppeteer(無頭 Chrome)和GraphQL API(Ruby on Rails )構建的。在移動設備上的界面如下所示:
Universe homepage 和 explore
沒有數據,只不過是空談。—— W. Edwards Deming
實驗室測試工具(Lab instruments)
實驗室測試工具允許在受控環境中,用預定義設備和網絡設置采集數據。借助這些工具,調試任何性能問題和具有良好重現性的測試就變得更加簡單。
Lighthouse是在本地計算機上審核 Chrome 頁面的出色工具。它還提供一些關于如何提高性能、可訪問性、SEO 等有用技巧。下面是一些模擬 Fast 3G 和 4 倍 CPU 減速的 Lighthouse 性能審核報告:
用 First Contentful Paint (FCP) 提高 10 倍性能的前后對照
然而,只使用實驗室測試工具的缺點是:它們不一定能發現真實世界的瓶頸問題,這些問題可能取決于終端用戶的設備、網絡、位置和很多其他因素。這就是為什么使用現場測試工具也很重要的原因。
現場測試工具(Field instrument)
現場測試工具使我們可以模擬和測量真實的用戶頁面負載。有很多有助于從實際設備中獲取真實性能數據的服務:
WebPageTest 報告
渲染內容的方法有很多,每種方法都有其優缺點:
客戶端渲染
之前,我們把我們的主頁和 Ember.js 框架一起實現為具有客戶端渲染的 SPA。我們遇到的一個問題是,Ember.js 應用程序包太大。這意味著,在瀏覽器下載、解析、編譯和執行 JavaScript 文件時,用戶只能看到一個空白的屏幕。
白屏
我們決定用React重建該應用程序的某些部分。
預渲染和服務器端渲染
例如,用React Router DOM構建的客戶端渲染應用程序的問題, 仍然和 Ember.js 的相同。JavaScript 開銷大,并且需要一些時間才能看到瀏覽器中的首次內容繪制(First Contentful Paint)。
當我們決定使用 React 后,我們馬上就用其它潛在的渲染選項進行試驗,以讓瀏覽器更快地渲染內容。
使用 React 的常規渲染選項
這就是我們為什么決定嘗試一些混合方法的原因,嘗試從每個渲染選項中獲得最佳效果。
運行時預渲染
Puppeteer是個 Node.js 庫,它允許使用無頭 Chrome。我們希望讓 Puppeteer 試試在運行時進行預渲染。這支持使用一種有趣的混合方法:服務器端用 Puppeteer 渲染,客戶端用激活渲染。這里有一些谷歌提供的有用竅門,關于如何使用無頭瀏覽器來進行服務器端渲染。
用于運行時預渲染 React 應用程序的 Puppeteer
使用這種方法有如下優點:
然而,我們在使用這個方法時遇到了一些挑戰:
使用 Puppeteer 進行服務器端渲染的體系結構
在 AWS Lambdas 和 GCP 函數上的 Puppeteer 響應時間
隨著我們越來越熟悉 Puppeteer,我們已經迭代了我們的初始方法(如下所示)。我們還進行著一些有趣實驗,通過一個無頭瀏覽器來渲染 PDF。還可以使用 Puppeteer 來進行自動端到端測試,甚至都不用寫任何代碼。現在,除了 Chrome,它還支持 Firefox。
混合渲染方法
在運行時使用 Puppeteer 很具挑戰性。這是我們為什么決定在構建時使用它,并借助一個在運行時可以從服務器端返回實際用戶生成內容的工具。與 Puppeteer 相比,它更穩定,并且吞吐量更大。
我們決定嘗試一下 Elixir 編程語言。Elixir 看起來像 Ruby,但是運行于 BEAM(Erlang VM)之上,旨在構建容錯且穩定的系統。
Elixir 使用Actor 并發模型。每個“Actor”(Elixir process)只占用很少的內存,約為 1-2KB。這樣允許同時運行數千個獨立進程。Phoenix是一個 Elixir web 框架,支持高吞吐量,并在獨立的 Elixir 過程中處理每個 HTTP 請求。
我們結合了這些方法,充分利用了它們各自的優點,滿足了我們的需要:
Puppeteer 用于預渲染,而 Phoenix 用于服務器端渲染
我們可以繼續構建一個簡單的瀏覽器 React 應用程序,不需要在終端用戶設備上等待 JavaScript 就可以快速加載初始頁面。
這讓內容 SEO 變得很友好,允許根據需要處理大量不同的頁面,并且更容易擴展。
這樣,我們可以構建高度交互的應用程序,和訪問 JavaScript 瀏覽器功能。
使用 Puppeteer 進行預渲染、使用 Phoenix 進行服務器端渲染和激發使用 React
內容分發網絡(CDN)
使用 CDN 可以實現內容緩存,并可以加速其在世界范圍內的分發。我們使用Fastly.com,它為超過 10% 的互聯網請求提供服務,并為各種公司使用,如 GitHub、Stripe、Airbnb、Twitter 等等。
Fastly 允許我們通過使用名為VCL的配置語言編寫自定義緩存和路由邏輯。下圖顯示了一個基本請求流的工作原理,根據路由、請求標頭等等來自定制每個步驟:
VCL 請求流
另一個提高性能的選擇是在邊緣使用 WebAssembly(WASM)和 Fastly。把它想象成使用無服務器,但是在邊緣使用這些編程語言,如 C、Rust、Go、TypeScript 等等。Cloudflare 有個類似的項目支持Workers上的 WASM.
盡可能多地緩存請求對提高性能很重要。CDN 級別上的緩存可以更快地為新用戶提供響應。通過發送 Cache-Control 頭來緩存可以加快瀏覽器中重復請求的響應時間。
大多數構建工具(如Webpack)允許給文件名添加哈希值。可以安全地緩存這些文件,因為更改文件將創建新的輸出文件名。
通過 HTTP/2 緩存和編碼的文件
GraphQL 緩存
發送 GraphQL 請求最常見的方法之一是使用 POST HTTP 方法。我們使用的一種方法是在 Fastly 級緩存一些 GraphQL 請求:
發送帶有 SHA256 URL 參數的 POST GraphQL 請求
以下是一些其它潛在的 GraphQL 緩存策略:
所有主流瀏覽器都支持帶有Content-Encoding頭的 gzip 來壓縮數據。這可以讓我們給瀏覽器發送的字節更少,這通常意味著內容傳遞會更快。如果瀏覽器支持的話,你還可以使用更有效的 brotli 壓縮算法。
HTTP/2 協議
HTTP/2是 HTTP 網絡協議(在 DevConsole 中是 h2)的新版本。切換到 HTTP/2 可以提升性能,這歸結于它和 HTTP/1.x 的這些不同之處:
HTTP/2 服務器推送
有很多編程語言和庫并不完全支持所有 HTTP/2 功能,原因是它們為現有工具和生態系統(如,rack)引入了破壞性更改。但是,即使在這種情況下,仍然可以使用 HTTP/2,至少可以部分使用。如:
HTTP/2 推送字體
推送關鍵的 JavaScript 和 CSS 也可以很有用。只是不要過度推送,并提防某些陷阱。
瀏覽器中的 JavaScript
包大小的預算
第一條 JavaScript 性能規則是不要使用 JavaScript。我這么認為。
如果我們已經有現成的 JavaScript 應用程序,那么設置預算可以改進包大小的可見性,并讓所有人都停留在同一個頁面上。超預算迫使開發人員三思而后行,并把規模的增加控制在最小程度。關于如何設置預算,在此舉幾個例子:
我們可以使用 bundlesize 包或 Webpack 性能提示和限制來追蹤預算:
Webpack 性能提示和限制
這是由 Sidekiq 的作者所寫的一篇熱門博文的標題
沒有代碼能比沒代碼運行得更快。沒有代碼能比沒代碼有更少的錯誤。沒有代碼能比沒代碼使用更少的內存。沒有代碼能比沒代碼更容易讓人理解。
不幸的是,JavaScript 依賴項的現實是,我們的項目很有可能使用數百個依賴項。試試 Is node_modules | wc -l。
在某些情況下,添加依賴項是必須的。在這種情況下,依賴項包的大小應該是在多個包之間進行選擇時的標準之一。我強烈推薦使用BundlePhobia:
BundlePhobia 發現向包中添加 npm 包的成本
使用代碼拆分可能是顯著提高 JavaScript 性能的最佳方法。它允許拆分代碼,并只傳遞用戶當前需要的那部分。以下是一些代碼拆分的例子:
借助 Webpack動態導入和具有Suspense的React.lazy,我們可以使用代碼拆分。
借助動態引入和具有 Suspense 的 React.lazy 的代碼拆分
我們構建了一個取代 React.lazy 的函數來支持命名導出,而不是默認導出。
異步和延遲腳本
所有主流瀏覽器支持腳本標簽上的異步和延遲屬性
以下顯示了在頭標簽中這些腳本之間的差異:
腳本獲取和執行的不同方法
盡管 JavaScript 的 100KB 與圖像的 100KB 相比,性能成本有很大的不同,但是,通常來說,盡量讓圖像保持比較小的文件大小很重要。
一種減小圖像大小的方法是,在受支持的瀏覽器中使用更輕量級的WebP圖像格式。對于那些不支持 WebP 的瀏覽器來說,可以使用以下策略:
WebP 圖像
僅當圖像在位于或接近視圖端口時才延遲加載圖像,對于具有大量圖像的初始頁面加載來說,這是最顯著的性能改進之一。我們可以在支持的瀏覽器中使用 IntersectionObserver功能,或使用一些可替換的工具來實現同樣的結果,例如,react-lazyload。
在滾動期間延遲加載圖像
加載常規圖像和漸進圖像的對比
我們可以考慮使用一些通用 CDN 或專用圖像 CDN,它們通常實現了這些圖像優化的大部分工作。
資源提示
資源提示讓我們可以優化資源的交付,減少往返次數,以及資源的獲取,以便在用戶瀏覽頁面時更快地傳遞內容。
帶有 link 標記的資源提示
提前預連接以避免 DNS、TCP 和 TLS 往返延遲
還有其他一些資源提示,如預渲染或DNS 預取。其中有一些可以在響應頭上指定。在使用資源提示時,請小心行事。很容易一開始就造成太多不必要的請求和下載太多數據,特別是如果用戶在使用蜂窩連接。
在不斷增長的應用中,性能是永無止境的過程,該過程通常需要在整個棧中不斷更改。
這個視頻提醒我,大家希望減少應用程序包的大小——我的同事
把一切你現在不需要的東西都扔出飛機!——電影《珍珠港》
以下是一個列表,表中是我們在使用或計劃嘗試的其他未提及的潛在性能改進:
令人興奮的想法無窮無盡,我們都可以拿來嘗試。我希望這些信息和這些案例研究可以啟發大家去思考應用程序中的性能。
據亞馬遜計算,頁面下載速度每下降 1 秒就可能造成年銷售額減少 13 億美元。沃爾瑪發現,加載時間每減少 1 秒,將使轉換量增加 2%。每 100ms 的改進還會帶來高達 1% 的收入增加。據谷歌計算,搜索結果每放慢 0.4 秒,那么每天的搜索次數有可能減少 8 百萬次。重構 Pinterest 頁面的性能使等待時間減少了 40%,而 SEO 流量增加了 15%,注冊轉化率增加了 15%。BBC 發現,其網站加載時間每增加一秒,就會多流失 10% 的用戶。對新的更快的 FT.com 的測試表明,用戶參與度提高了 30%,這意味著更多的訪問次數和更多的內容消費。Instagram 通過減少顯示評論所需 JSON 的響應大小,將展示次數和用戶個人資料滾動互動量增加了 33%。
點擊“了解更多”,獲取更多優質閱讀
讀
本文主要總結了在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界面進行拆分,之后由服務器分批進行推送,這樣做有兩個好處:
這個思路對需要加載資源較多的頁面有很明顯的效果,在我們此次的界面優化中,頁面的FCP與LCP均有300ms-400ms的性能提升,在進行vm界面的數據拆分時,有以下幾個技巧:
2.3. 注意事項
此次優化的應用與界面本身歷史包袱很重,在進行流式改造的過程中,我們遇到了不少的阻力與挑戰,在解決問題的過程也學到了很多東西,這部分主要對遇到的問題進行整理。
/**
* 防止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能正常寫入,需要指定cookie的SameSite=None。
我們的項目中使用的模板引擎為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);
}
}
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;
}
}
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
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'
在開始,我們使用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);
}
}
四、措施三:數據直出
數據直出有利有弊,對于頁面的加載性能有正向影響的同時,也會同時導致HTTP的response增大以及服務端RT的升高。數據直出與流式分塊傳輸相結合的效果可能會更好,當服務端分塊響應HTTP請求時,本身的response就被切割成多塊,單次大小得到了控制,流式分塊傳輸下,服務端分批執行數據準備的策略也能很好的緩沖RT增長的問題。
五、措施四:本地緩存
以我們遇到的一個問題為例,我們的云盤文件列表需要在后端準備好文件所屬人的昵稱,這是在后端服務器由用戶id調用會員的rpc接口實時查詢的。分析這個場景,我們不難發現,同一時間,IM場景下的文件所屬人往往是其中歸屬在聊天的幾個人名下的,因此,可以利用HashMap作為緩存rpc查詢到的會員昵稱,避免重復的查詢與調用。
六、措施五:下線歷史債務
針對有歷史包袱的應用,歷史債務導致的額外耗時往往很大,這些歷史代碼可能包括以下幾類:
作者:樹塔
來源-微信公眾號:阿里云開發者
出處:https://mp.weixin.qq.com/s/06eND-fUGQ7Y6gwJxmvwQQ
*請認真填寫需求信息,我們會在24小時內與您取得聯系。