击上方 "程序员小乐"关注, 星标或置顶一起成长
近,我们把 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小时内与您取得联系。