Faster micro-frontends: optimising CDN behaviour for performance

Alex O'Callaghan

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

我们刚刚完成了将 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 边缘规则,因此决定在同一个 function 中实现 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 - 宿主 shell 的入口文件,它是可变的,每次 shell 部署都会发生变化。
  • 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,确保浏览器始终向服务器检查文件的最新版本。现在,通过 CloudFront function 的逻辑,我们只需要使一个 index.html 缓存键失效,因此或许可以采用与 remoteEntry.js 类似的方法进一步优化;不过目前我们保留了现有的 no-cache 行为,以确保 shell 更新能够快速推出。

衡量影响

我们使用 Grafana Faro 收集生产环境的 RUM(真实用户监控)数据,并利用这些数据监测变更对性能指标的影响。随着流量从 Akamai 迁移到新的 CloudFront 分发版,我们开始看到页面加载相关指标全面改善。

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

这些改善主要得益于 TTFB 的降低,并进一步带动了 FCP 和 LCP 的改善。不过,我们也可以看到 TTFB 与 LCP 之间的差距在缩小,这表明缓存方面的改进也帮助其他资源更快地从浏览器或 CDN 缓存中加载。

迁移前后第 75 百分位的核心 Web 指标(毫秒)
指标之前(Akamai)之后(CloudFront)
TTFB2484ms1074ms
FCP3524ms1988ms
LCP6040ms3788ms
迁移前后第 75 百分位的核心 Web 指标(毫秒)

我们可以根据 Faro 数据拆解 TTFB 请求的组成部分,看到 request_duration 大幅下降。遗憾的是,Faro 默认不会对 <script> 标签资源进行请求耗时监测,因此我们无法准确看到资源缓存对请求加载时间的改善幅度。

TTFB 子项(p50,毫秒):request_duration(服务器响应所需时间)从约 1056 毫秒降至约 184 毫秒,几乎带来了全部收益。DNS、连接和缓存几乎没有变化。
指标之前(Akamai)之后(CloudFront)
DNS56ms58ms
连接47ms33ms
缓存4ms4ms
请求1056ms184ms
等待11ms22ms
TTFB 子项(p50,毫秒):request_duration(服务器响应所需时间)从约 1056 毫秒降至约 184 毫秒,几乎带来了全部收益。DNS、连接和缓存几乎没有变化。

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

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

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

Faro 会为每个会话标记一个指向用户上一个会话的引用,这让我们能够将首次访问(冷缓存)加载与回访(热缓存)加载区分开来。我们预计回访用户会最明显地受益于新的缓存策略。从首次内容绘制(FCP)来看,迁移前回访用户比新用户快约 17%,迁移后这一差距扩大到约 29%。

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

我们没有看到 LCP 出现对比明显的变化,因为 LCP 主要受应用渲染逻辑和数据获取 API 调用影响,而不是 JS 加载速度。在这方面,新用户和回访用户都改善了约 38%,幅度相同。

下一步

在前端应用的整体性能方面,我们仍有许多可以改进之处。Web Vitals 建议,p75 的“良好”LCP 应低于 2.5 秒。我们目前约为 3.8 秒,仍有改进空间。

我们的架构旨在让分布式团队能够快速开发功能;虽然 SPA 和微前端帮助我们实现了这一目标,但代价是性能下降。在平台层面,我们还可以进一步改进 index.html 缓存,并将骨架加载屏幕内联到 HTML 文件中,以改善 FCP。最终,要进一步降低 LCP,还需要产品团队投入精力,优化前端和后端逻辑中通往初始渲染的关键路径。

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

原文由 Alex O'Callaghan 发布

本文章由 gpt-5.6-luna 进行翻译