整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

CSS 与网络性能,看了都说好

CSS 与网络性能,看了都说好

击上方 "程序员小乐"关注, 星标或置顶一起成长

近,我们把 Universe.com 的主页性能提高了 10 几倍。让我们一起来探索一下我们是如何实现这个结果的,涉及到了哪些技术。

一开始,我们先来看看,为什么网站性能如此重要(在本文末尾附有本案例研究的链接):

  • 用户体验:糟糕的性能导致无响应,从 UI 和 UX 角度来看,这可能会让用户感到沮丧。
  • 转化率和收入:通常,响应缓慢的网站会导致客户流失,并对转化率和收入产生不好的影响。
  • SEO:从2019 年 7 月 1 日开始,谷歌将对所有新网站默认启用移动优先索引。如果网站在移动设备上的响应缓慢,并且没有适合移动设备的内容,那么它们的排名会降低。

在本文中,我们将简要介绍帮助我们提高页面性能的以下几个主要方面:

  • 性能测量:实验室和现场测试工具。
  • 渲染:客户端和服务器端渲染,预渲染和混合渲染方法。
  • 网络:CDN、缓存、GraphQL 缓存、编码、HTTP/2 和服务器推送(Server Push)。
  • 浏览器中的 JavaScript:包大小预算、代码拆分、异步和延迟脚本、图像优化(WebP、延迟加载、渐进)和资源提示(预加载、预取、预连接)。

针对某些情况,我们的主页是用 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——允许在不同位置的实际设备上执行来自不同浏览器的测试。
  • Test My Site——使用基于 Chrome 使用情况统计的 Chrome 用户体验报告(Chrome User Experience Report,简称CrUX);它对公众开放,并每月更新一次。
  • PageSpeed Insights——结合了实验室(Lighthouse)和现场(CrUX)数据。


WebPageTest 报告

渲染

渲染内容的方法有很多,每种方法都有其优缺点:

  • 服务器端渲染(Server-side rendering,简称 SSR)是在服务器端为浏览器获取最终 HTML 文档的过程。优点:搜索引擎可以爬取网站而不执行 JavaScript(SEO)、快速初始页面加载、代码只存在于服务器端。缺点:没有丰富的网站交互、重新下载整个页面,对浏览器功能的访问有限制。
  • 客户端渲染是使用 JavaScript 在浏览器中渲染内容的过程。优点:丰富的网站交互、在初始下载后根据路由变化快速渲染、可以访问现代浏览器功能(如,Service Workers的离线支持)。缺点:不支持 SEQ、初始页面加载速度慢、通常需要在服务器端执行单页面应用程序(Single Page Application、简称 SPA)和 API。
  • 预渲染和服务器端渲染类似,但是它是在构建时期提前发生的,而不是在运行时发生的。优点:服务构建静态文件通常比运行服务器简单、支持 SEO、初始页面加载快。缺点:如果代码有任何变化,需要预先预渲染所有可能的页面、重新加载整个页面、站点交互不够丰富、访问浏览器功能受限。

客户端渲染

之前,我们把我们的主页和 Ember.js 框架一起实现为具有客户端渲染的 SPA。我们遇到的一个问题是,Ember.js 应用程序包太大。这意味着,在浏览器下载、解析、编译和执行 JavaScript 文件时,用户只能看到一个空白的屏幕。

白屏

我们决定用React重建该应用程序的某些部分。

  • 我们的开发人员已经熟悉构建 React 应用程序(如,嵌入式小部件)。
  • 我们已经有了一些 React 组件库,可以在多个项目之间共享它们。
  • 新页面有一些交互式 UI 元素。
  • 有一个拥有大量工具的庞大的 React 生态系统。
  • 借助浏览器中的 JavaScript,可以构建具有大量良好功能的渐进式 web 应用程序(Progressive Web App)。

预渲染和服务器端渲染

例如,用React Router DOM构建的客户端渲染应用程序的问题, 仍然和 Ember.js 的相同。JavaScript 开销大,并且需要一些时间才能看到浏览器中的首次内容绘制(First Contentful Paint)。

当我们决定使用 React 后,我们马上就用其它潜在的渲染选项进行试验,以让浏览器更快地渲染内容。

使用 React 的常规渲染选项


  • Gatsby.js使我们可以用 React 和 GraphQL 预渲染页面。Gatsby.js 是个很棒的工具,它支持很多开箱即用的性能优化。然而,对我们来说,预渲染没有用,因为我们可能有无数个页面,它们包含用户生成的内容。
  • Next.js是流行的 Node.js 框架,它允许服务器端用 React 渲染。然而,Next.js 很自我,需要使用其路由、CSS 解决方案等等。我们现有的组件库是为浏览器而构建的,与 Node.js 不兼容。

这就是我们为什么决定尝试一些混合方法的原因,尝试从每个渲染选项中获得最佳效果。

运行时预渲染

Puppeteer是个 Node.js 库,它允许使用无头 Chrome。我们希望让 Puppeteer 试试在运行时进行预渲染。这支持使用一种有趣的混合方法:服务器端用 Puppeteer 渲染,客户端用激活渲染。这里有一些谷歌提供的有用窍门,关于如何使用无头浏览器来进行服务器端渲染。

用于运行时预渲染 React 应用程序的 Puppeteer


使用这种方法有如下优点:

  • 可以使用 SSR,对 SEO 来说,这很棒。爬虫程序不需要执行 JavaScript 就能看到内容。
  • 允许构建简单浏览器 React 应用程序一次,然后把它用在服务器端和浏览器中。让浏览器应用程序更快地自动让 SSR 更快,这是双赢。
  • 在服务器上用 Puppeteer 渲染页面通常比在终端用户的移动设备上更快(连接更好, 硬件更好)。
  • 激活允许用对 JavaScript 浏览器功能的访问来构建丰富的 SPA。
  • 我们无需事先知道所有可能的页面来预渲染它们。

然而,我们在使用这个方法时遇到了一些挑战:

  • 吞吐量是主要问题。在单独的无头浏览器进程中执行每个请求消耗了大量资源。你可以使用单个无标题浏览器进程,并在单独的选项卡中运行多个请求。然而,使用多个选项卡将会使整个进程的性能下降了。


使用 Puppeteer 进行服务器端渲染的体系结构


  • 稳定性。扩展或缩小很多无头浏览器,让流程保持“热度”及平衡工作负载是个挑战。我们尝试了不同的托管方法:从 Kubernetes 集群自托管到用 AWS Lambda 和 Google Cloud Functions 的无服务器。我们注意到,后者在用到 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 用于服务器端渲染


  • Puppeteer在构建时用我们希望的方式预渲染 React 页面,并以 HTML 文件形式保存它们(App Shell 来自PRPL 模式)。

我们可以继续构建一个简单的浏览器 React 应用程序,不需要在终端用户设备上等待 JavaScript 就可以快速加载初始页面。

  • 我们的Phoenix应用程序服务于这些预渲染页面,并动态地把实际内容注入到 HTML 中。

这让内容 SEO 变得很友好,允许根据需要处理大量不同的页面,并且更容易扩展。

  • 客户端接收并立即显示 HTML,然后激发Recat DOM状态以继续作为常规 SPA。

这样,我们可以构建高度交互的应用程序,和访问 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 请求:

  • 我们的 React 应用程序注释了可以缓存的 GraphQL 请求。
  • 发送 HTTP 请求前,我们通过从请求正文构建哈希值来附加 URL 参数,该请求正文包括 GraphQL 请求和变量(我们使用Apollo Client自定义 fetch)。
  • 默认情况下,Varnish(和 Fastly)使用整个 URL 作为缓存键的一部分。
  • 这允许我们继续在请求正文中使用 GraphQL 查询发送 POST 请求,并在边缘缓存,而不会访问我们的服务器。


发送带有 SHA256 URL 参数的 POST GraphQL 请求


以下是一些其它潜在的 GraphQL 缓存策略:

  • 在服务器端缓存:整个 GraphQL 请求,在解析器级别上或通过注释模式声明性地进行缓存。
  • 使用持久的 GraphQL 查询和发送 GET/graphql/:queryId 以便能够依赖 HTTP 缓存。
  • 通过使用自动化工具(如Apollo Server 2.0)或使用 GraphQL 特定的 CDN(如FastQL)与 CDN 集成。

编码

所有主流浏览器都支持带有Content-Encoding头的 gzip 来压缩数据。这可以让我们给浏览器发送的字节更少,这通常意味着内容传递会更快。如果浏览器支持的话,你还可以使用更有效的 brotli 压缩算法。

HTTP/2 协议

HTTP/2是 HTTP 网络协议(在 DevConsole 中是 h2)的新版本。切换到 HTTP/2 可以提升性能,这归结于它和 HTTP/1.x 的这些不同之处:

  • HTTP/2 是二进制的,不是文本。解析更高效,更紧凑。
  • HTTP/2 是多路复用的,这意味着 HTTP/2 可以通过单个 TCP 连接并行发送多个请求。它让我们不用担心每个主机限制和域分片的浏览器连接。
  • 它使用头压缩来减少请求 / 响应大小开销。
  • 允许服务器主动推送响应。该功能相当有趣。

HTTP/2 服务器推送

有很多编程语言和库并不完全支持所有 HTTP/2 功能,原因是它们为现有工具和生态系统(如,rack)引入了破坏性更改。但是,即使在这种情况下,仍然可以使用 HTTP/2,至少可以部分使用。如:

  • 在常规 HTTP/1.x 服务器前使用 HTTP/2 设置代理服务器,如h2o或nginx。例如 Puma 和 Rails 上的 Ruby 可以发送Early Hints,这可以启用 HTTP/2 服务器推送,但受到一些限制。
  • 使用支持 HTTP/2 的 CDN 提供静态资产。例如,我们用这种方法给客户端推送字体和一些 JavaScript 文件。


HTTP/2 推送字体

推送关键的 JavaScript 和 CSS 也可以很有用。只是不要过度推送,并提防某些陷阱。

浏览器中的 JavaScript

包大小的预算

第一条 JavaScript 性能规则是不要使用 JavaScript。我这么认为。

如果我们已经有现成的 JavaScript 应用程序,那么设置预算可以改进包大小的可见性,并让所有人都停留在同一个页面上。超预算迫使开发人员三思而后行,并把规模的增加控制在最小程度。关于如何设置预算,在此举几个例子:

  • 根据我们的需要或一些推荐值使用数字。例如,小于 170KB的缩小和压缩的 JavaScrip。
  • 把当前的包大小作为基准,或尝试把它减少,例如 10%。
  • 试试我们的竞争对手中最快的网站,并相应地设置预算。

我们可以使用 bundlesize 包或 Webpack 性能提示和限制来追踪预算:

Webpack 性能提示和限制

删除依赖项

这是由 Sidekiq 的作者所写的一篇热门博文的标题

没有代码能比没代码运行得更快。没有代码能比没代码有更少的错误。没有代码能比没代码使用更少的内存。没有代码能比没代码更容易让人理解。

不幸的是,JavaScript 依赖项的现实是,我们的项目很有可能使用数百个依赖项。试试 Is node_modules | wc -l。

在某些情况下,添加依赖项是必须的。在这种情况下,依赖项包的大小应该是在多个包之间进行选择时的标准之一。我强烈推荐使用BundlePhobia:

BundlePhobia 发现向包中添加 npm 包的成本

代码拆分

使用代码拆分可能是显著提高 JavaScript 性能的最佳方法。它允许拆分代码,并只传递用户当前需要的那部分。以下是一些代码拆分的例子:

  • 在单独的 JavaScript 块中分别加载路由
  • 页面上可以不立即显示的组件,例如,在页面下方的模态、页脚。
  • 在所有主流浏览器中,polyfills和ponyfills都支持最新的浏览器功能。
  • 通过使用 Webpack 的 SplitChunksPlungin,避免代码重复。
  • 根据需要定位文件,以避免一次性发送所有我们支持的语言。

借助 Webpack动态导入和具有Suspense的React.lazy,我们可以使用代码拆分。

借助动态引入和具有 Suspense 的 React.lazy 的代码拆分

我们构建了一个取代 React.lazy 的函数来支持命名导出,而不是默认导出。

异步和延迟脚本

所有主流浏览器支持脚本标签上的异步和延迟属性


加载 JavaScript 的不同方法

  • 内联脚本对于加载小型关键 JavaScript 代码非常有用。
  • 当用户或任何其他脚本(例如,分析脚本)不需要该脚本,要获取 JavaScript 而不妨碍 HTML 解析时,使用带async的脚本非常有用。
  • 从性能的角度看,要获取和执行非关键 JavaScript,并且不阻碍 HTML 解析,那么,使用带defer的脚本可能是最佳方法。此外,它确保调用脚本时的执行顺序,如果一个脚本依赖另一个脚本,那么这个方法会很有用。

以下显示了在头标签中这些脚本之间的差异:

脚本获取和执行的不同方法

图像优化

尽管 JavaScript 的 100KB 与图像的 100KB 相比,性能成本有很大的不同,但是,通常来说,尽量让图像保持比较小的文件大小很重要。

一种减小图像大小的方法是,在受支持的浏览器中使用更轻量级的WebP图像格式。对于那些不支持 WebP 的浏览器来说,可以使用以下策略:

  • 退回到常规 JPEG 或 PNG 格式(一些 CDN 根据浏览器的 Accept 请求头自动执行)
  • 在检测到浏览器支持后,加载并使用WebP polyfill。
  • 使用 Service Workers 来侦听以获取请求,如果 WebP 受到支持,那么就更改实际的 URL 以使用 WebP。



WebP 图像

仅当图像在位于或接近视图端口时才延迟加载图像,对于具有大量图像的初始页面加载来说,这是最显著的性能改进之一。我们可以在支持的浏览器中使用 IntersectionObserver功能,或使用一些可替换的工具来实现同样的结果,例如,react-lazyload。

在滚动期间延迟加载图像

其他一些图像优化可能包括:

  • 降低图像的质量以减少图像的尺寸。
  • 调整大小并尽可能加载最小的图像。
  • 使用srcset图像属性为高分辨率视网膜显示器自动加载高质量图像。
  • 使用渐进式图像,先立即显示出模糊的图像


加载常规图像和渐进图像的对比

我们可以考虑使用一些通用 CDN 或专用图像 CDN,它们通常实现了这些图像优化的大部分工作。

资源提示

资源提示让我们可以优化资源的交付,减少往返次数,以及资源的获取,以便在用户浏览页面时更快地传递内容。

带有 link 标记的资源提示

  • 预加载(preload)在当前页面加载的后台下载资源,并会实际用于当前页面(高优先级)。
  • 预取(prefetch)的工作原理和预加载类似,都是获取资源并缓存它们,但用于未来用户的导航(低优先级)。
  • 预连接(preconnect)允许在 HTTP 请求在实际发送到服务器之前,设置早期连接。


提前预连接以避免 DNS、TCP 和 TLS 往返延迟

还有其他一些资源提示,如预渲染或DNS 预取。其中有一些可以在响应头上指定。在使用资源提示时,请小心行事。很容易一开始就造成太多不必要的请求和下载太多数据,特别是如果用户在使用蜂窝连接。

结论

在不断增长的应用中,性能是永无止境的过程,该过程通常需要在整个栈中不断更改。

这个视频提醒我,大家希望减少应用程序包的大小——我的同事


把一切你现在不需要的东西都扔出飞机!——电影《珍珠港》


以下是一个列表,表中是我们在使用或计划尝试的其他未提及的潜在性能改进:

  • 使用 Service Workers 进行缓存、脱机支持及卸载主线程。
  • 内联关键 CSS 或使用功能性 CSS,以便长期减小尺寸大小。
  • 使用如 WOFF2 而不是 WOFF 的字体格式(最高可压缩一半大小)。
  • 浏览器列表保持更新。
  • 使用webpack-bundle-analyzer进行构建块的可视化分析。
  • 优选较小的包(例如,date-fns)和允许减小尺寸大小的插件(如,lodash-webpack-plugin)。
  • 试试preact、lit-html或svelte。
  • 在CI 中运行 Lighthouse。
  • 渐进激发和用React进行流处理。

令人兴奋的想法无穷无尽,我们都可以拿来尝试。我希望这些信息和这些案例研究可以启发大家去思考应用程序中的性能。

据亚马逊计算,页面下载速度每下降 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界面进行拆分,之后由服务器分批进行推送,这样做有两个好处:

  • 服务端分批进行数据准备,可以减少首次需要准备的数据量,极大缩短准备时间。
  • 浏览器分批接收数据,当接收到第一部分的数据时,可以立刻进行js渲染,提升其利用率。

这个思路对需要加载资源较多的页面有很明显的效果,在我们此次的界面优化中,页面的FCP与LCP均有300ms-400ms的性能提升,在进行vm界面的数据拆分时,有以下几个技巧:

  • 注意界面资源加载的依赖关系,前序界面不能依赖后序界面的变量。
  • 将偏静态与核心的资源前置,后端服务器可以快速完成数据准备并返回第一段html供前端加载。


2.3. 注意事项


此次优化的应用与界面本身历史包袱很重,在进行流式改造的过程中,我们遇到了不少的阻力与挑战,在解决问题的过程也学到了很多东西,这部分主要对遇到的问题进行整理。

  1. 二方包或自定义的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;

}
  1. 谷歌浏览器禁止跨域名写入cookie,我们的应用界面会以iframe的形式嵌入其他界面,谷歌浏览器正在逐步禁止跨域名写cookie,如下所示:

为了确保cookie能正常写入,需要指定cookie的SameSite=None。

  1. 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);

    }

}
  1. 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;

    } 

}
  1. 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

  1. 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'
  1. 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);

    }

}

四、措施三:数据直出


  1. 静态图片直出,页面上有静态的loge图片,原本为cdn地址,在浏览器渲染时,需要建联并会抢占线程,对于这类不会发生发生变化的图片,可以直接替换为base64的形式,js可以直接加载。
  2. 加载数据直出,这部分需要根据具体业务来分析,部分业务数据是浏览器运行js脚本在本地二次请求加载的,由于低端机以及本地浏览器的能力限制,如果需要加载的数据很多,就很导致js线程的挤占,拖慢整体的时间,因此,可以考虑在服务器将部分数据预先加载好,随http请求一起给浏览器,减少这部分的卡点。

数据直出有利有弊,对于页面的加载性能有正向影响的同时,也会同时导致HTTP的response增大以及服务端RT的升高。数据直出与流式分块传输相结合的效果可能会更好,当服务端分块响应HTTP请求时,本身的response就被切割成多块,单次大小得到了控制,流式分块传输下,服务端分批执行数据准备的策略也能很好的缓冲RT增长的问题。

五、措施四:本地缓存


以我们遇到的一个问题为例,我们的云盘文件列表需要在后端准备好文件所属人的昵称,这是在后端服务器由用户id调用会员的rpc接口实时查询的。分析这个场景,我们不难发现,同一时间,IM场景下的文件所属人往往是其中归属在聊天的几个人名下的,因此,可以利用HashMap作为缓存rpc查询到的会员昵称,避免重复的查询与调用。

六、措施五:下线历史债务


针对有历史包袱的应用,历史债务导致的额外耗时往往很大,这些历史代码可能包括以下几类:

  • 未下线的实验或者分流接口调用;
    • 时间线拉长,这部分的代码残骸在所难免,而且积少成多,累计起来往往有几十上百毫秒的资源浪费,再加上业务开发时,大家往往没有额外资源去评估这部分的很多代码是否可以下线,因此可以借助性能优化的契机进行治理。
  • 已经废弃的vm变量与重复变量治理。
    • 对vm变量的盘点过程中发现有很多之前在使用但现在已经废弃的变量。当然,这部分变量的需要前后端同学共同梳理,防止下线线上依旧依赖的变量。

作者:树塔

来源-微信公众号:阿里云开发者

出处:https://mp.weixin.qq.com/s/06eND-fUGQ7Y6gwJxmvwQQ