Faster micro-frontends: optimising CDN behaviour for performance

Alex O'Callaghan

更快的微前端:优化 CDN 行为以提升性能

原文由 Alex O'Callaghan 发布,订阅该博客

我们刚刚完成了 CDN 层从 Akamai 到 CloudFront 的迁移,并针对微前端架构优化了缓存策略。性能指标得到了显著提升,尤其是首字节时间(TTFB)在 p90 分位下降了 54%,带动最大内容绘制(LCP)在 p90 分位降低了约 36%。

几个月前我曾写过关于微前端架构的缓存策略的文章。当时我们在一次故障后禁用了 Akamai 缓存,采用的是三跳架构(Akamai -> CloudFront -> S3),并依赖 CDN 层面的显式缓存规则来控制缓存。大多数资源没有显式设置缓存控制,缓存行为分散在 S3 元数据、Akamai 规则以及 CDN/浏览器的启发式策略之间。

去掉 Akamai:从三跳到两跳

Akamai 长期以来是我们架构中一直想移除的额外复杂性。我们其余大部分基础设施都在 AWS 上,自从通过 CloudFront 引入微前端架构后,Akamai 就成了一个增加延迟和复杂度的额外跳点。

基础设施团队梳理并迁移了多年来积累的各类 Akamai 规则到 CloudFront,解决了一系列问题,从对旧版 TLS 的支持到默认安全行为的变更。流量在三周内逐步从 Akamai 切换到了 CloudFront。

本文将重点介绍这次迁移对微前端架构的影响,以及我们同步为改善缓存行为所做的一些改动。

迁移前

UserAkamaiCloudFrontPython servicesDjango MPAS3 bucket

迁移后

UserCloudFrontS3 bucketPython servicesDjango MPA

源站路由:用显式逻辑替代 404 回退

过去我们依赖 CloudFront 的自定义错误响应来为单页应用路由回退到 index.html 文件。这意味着每当用户请求的路径不匹配任何静态资源时,请求都会先回源,得到一个不存在的对象,再回退到 index.html 并以 200 状态码返回。

由于我们已经在构建一个 CloudFront Function 来承载原有 Akamai 的大量边缘规则,我们决定在同一个函数中实现 SPA 回退逻辑。这样就能在边缘完成路由,在请求到达源站之前就处理好。

function handler(event) {
  var request = event.request;
  var uri = request.uri || '/';
  var qs = request.querystring || {};

  // ... other routing logic, omitted for simplicity

  // SPA fallback: if the path is extensionless, rewrite to index.html
  var lastSegment = uri.split('/').pop();
  if (lastSegment === '' || lastSegment.indexOf('.') === -1) {
    request.uri = '/path/to/index.html';
  }

  return request;
}

这样做让行为更加明确、可预测,也降低了在请求静态资源时掩盖源站真实错误的风险——我们之前在一次 CloudFront/S3 故障中就曾因此受到影响

同时,由于每个 SPA 路由请求都避免了对源站的两次请求,TTFB 性能也得到了提升。

按资源类型设置显式缓存头

在微前端部署中,我们通过 S3 提供了三类资源:

  • index.html —— 宿主外壳的入口文件,可变,每次外壳部署都会变化。
  • remoteEntry.js —— 每个微前端的入口文件,同样可变,微前端更新时就会变化。
  • assets —— 每个微前端的 JS 分块及其他资源,不可变,文件名中包含内容哈希。

我们不再混用 CDN 规则、S3 元数据和浏览器启发式策略,而是在上传到 S3 时就为每类资源显式设置缓存头。

现在的部署流水线会在每个对象上设置 Cache-Control 元数据:

# Upload assets with immutable caching
rclone copy -vv \
  --exclude 'index.html' \
  --exclude 'remoteEntry.js' \
  -M --metadata-set 'Cache-Control=max-age=31536000, immutable' \
  ./dist $DEST
# Upload remoteEntry.js with short caching
rclone copy -vv \
  -M --metadata-set 'Cache-Control=max-age=30, s-maxage=86400' \
  ./dist/remoteEntry.js $DEST
# Upload index.html with no caching
rclone copy -vv \
  -M --metadata-set 'Cache-Control=no-cache' \
  ./dist/index.html $DEST

静态资源

微前端构建生成的所有资源都在文件名中使用了 Webpack 的 [contenthash],这意味着分块内容的任何变化都会产生新的文件名。因此我们可以为这些资源设置很长的缓存时间,而无需担心提供过期内容。

remoteEntry.js

remoteEntry.js 文件是每个微前端的入口,会指向需要加载的各个 JS 分块。由于每个微前端都是独立部署的,文件名需要固定在众所周知的路径上。我们为终端用户设置了较短的缓存时间,以防止浏览器过久地缓存过期版本。s-maxage 指令则允许共享缓存(如 CloudFront)将文件缓存更长时间,从而减轻源站压力。

我们在部署流水线中增加了一步 CloudFront 缓存失效操作,以确保 remoteEntry.js 更新时,CloudFront 中的缓存版本会被清除,用户在下一次请求时就能拿到新版本:

aws cloudfront create-invalidation \
  --distribution-id $DISTRIBUTION_ID \
  --paths '/path/to/remoteEntry.js'

注意:部分客户通过自有代理访问我们的应用,这些代理可能会根据 s-maxage 指令进行缓存。我们会在 CloudFront 响应中去掉 Cache-Control 头里的 s-maxage,这样只有我们自己的 CDN 会按较长时间缓存该文件,下游消费者始终拿到的是较短的缓存时间。

index.html

对于 index.html,我们设置了 Cache-Control: no-cache,以确保浏览器每次都会向服务器校验最新版本。理论上我们可以用类似 remoteEntry.js 的方式进一步优化它,因为现在借助 CloudFront Function 的逻辑,index.html 只有一个需要清除的缓存键,但为了确保外壳更新能够快速生效,我们暂时保留了现有的 no-cache 行为。

效果衡量

我们使用Grafana Faro 采集生产环境的 RUM 数据,并以此监控改动对性能指标的影响。随着流量从 Akamai 迁移到新的 CloudFront 分发,页面加载相关的各项指标都开始全面改善。

指标p50p75p90
TTFB (ms)1493 -> 507 (-66%)2484 -> 1074 (-57%)4483 -> 2041 (-54%)
FCP (ms)2284 -> 1140 (-50%)3524 -> 1988 (-44%)5308 -> 3440 (-35%)
LCP (ms)4312 -> 2688 (-38%)6040 -> 3788 (-37%)8608 -> 5516 (-36%)

这主要得益于 TTFB 的降低,并连带带来了 FCP 和 LCP 的提升。但我们也能看到 TTFB 与 LCP 之间的差距在缩小,说明缓存优化也在帮助其他资源更快地从浏览器或 CDN 缓存中加载。

核心网页指标 75 分位值(毫秒),迁移前后对比
指标迁移前(Akamai)迁移后(CloudFront)
TTFB2484ms1074ms
FCP3524ms1988ms
LCP6040ms3788ms
核心网页指标 75 分位值(毫秒),迁移前后对比

我们可以从 Faro 数据中拆解 TTFB 请求的各个组成部分,可以看到 request_duration 大幅下降。遗憾的是,Faro 默认不会对 <script> 标签资源的请求时间进行插桩,因此我们无法精确看到资源缓存对请求加载时间带来了多大改善。

TTFB 子项(p50,毫秒):request_duration——服务器响应耗时——从约 1056 毫秒降至约 184 毫秒,贡献了几乎全部收益。DNS、连接和缓存几乎没有变化。
指标迁移前(Akamai)迁移后(CloudFront)
DNS56ms58ms
Connection47ms33ms
Cache4ms4ms
Request1056ms184ms
Waiting11ms22ms
TTFB 子项(p50,毫秒):request_duration——服务器响应耗时——从约 1056 毫秒降至约 184 毫秒,贡献了几乎全部收益。DNS、连接和缓存几乎没有变化。

虽然无法在 Faro 中看到资源的请求耗时,但我们可以通过 CloudFront 按对象的缓存统计看到缓存的效果。筛选微前端资源后可以看到,边缘缓存命中率从 52% 上升到了 95%。两类可缓存资源均接近 100%:remoteEntry.js 从 63% 提升至 100%,不可变的内容哈希分块从 48% 提升至 100%。

微前端资源在 CloudFront 边缘的缓存命中率(命中数 / 请求数),迁移前后对比。
指标迁移前(Akamai)迁移后(CloudFront)
remoteEntry.js63%100%
不可变分块48%100%
所有微前端请求52%95%
微前端资源在 CloudFront 边缘的缓存命中率(命中数 / 请求数),迁移前后对比。

大部分提升来自消除了重新验证。此前由于没有显式缓存头,约 40% 的资源请求都是重新验证:CloudFront 虽然持有文件,但几乎每次请求都会回源校验(304 Not Modified)。为内容哈希分块设置 Cache-Control: immutable、为 remoteEntry.js 设置 s-maxage,将这些往返变成了真正的缓存命中。这既降低了回源带宽、节省了成本,也提升了性能。

Faro 会为每个会话打上对用户前一次会话的引用标记,这让我们能够区分首次(冷缓存)加载和回访(热缓存)加载。我们预期回访用户能从新的缓存策略中获益最多。从首次内容绘制来看,迁移前回访用户比新用户快约 17%,迁移后这一差距扩大到了约 29%。

FCP(p50,毫秒)按新用户与回访用户区分
指标迁移前(Akamai)迁移后(CloudFront)
新用户2296ms1156ms
回访用户1912ms816ms
FCP(p50,毫秒)按新用户与回访用户区分

在 LCP 上我们没有看到类似的对比变化,LCP 更多由应用渲染逻辑和数据获取接口调用决定,而非 JS 加载速度。在这项指标上,新用户和回访用户的提升幅度相同,均为约 38%。

下一步

在前端应用的性能方面,我们仍有很大的提升空间。Web Vitals 认为 p75 下“良好”的 LCP 应低于 2.5 秒,而我们目前约为 3.8 秒,仍有改进余地。

我们的架构旨在让分布式团队更快地开发功能,虽然 SPA 和微前端有助于实现这一目标,却是以性能为代价的。在平台层面,我们仍可在 index.html 缓存以及将骨架屏内联到 HTML 文件以优化 FCP 等方面做更多改进。归根结底,要进一步降低 LCP,还需要产品团队共同关注,优化从前端到后端逻辑的首屏渲染关键路径。

此外,引入缓存失效步骤也给部署流水线增加了一定的复杂性。CloudFront 缓存标签为我们组织缓存失效逻辑提供了灵活的方式,我们正考虑利用它来简化逻辑,并提升按需清除缓存资源的能力。

本文章由 muse-spark-1.2-contributor 进行翻译

评论