更快的微前端:优化 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。
本文将重点介绍这次迁移对微前端架构的影响,以及我们同步为改善缓存行为所做的一些改动。
迁移前
迁移后
源站路由:用显式逻辑替代 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 分发,页面加载相关的各项指标都开始全面改善。
| 指标 | p50 | p75 | p90 |
|---|---|---|---|
| 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 缓存中加载。
| 指标 | 迁移前(Akamai) | 迁移后(CloudFront) |
|---|---|---|
| TTFB | 2484ms | 1074ms |
| FCP | 3524ms | 1988ms |
| LCP | 6040ms | 3788ms |
我们可以从 Faro 数据中拆解 TTFB 请求的各个组成部分,可以看到 request_duration 大幅下降。遗憾的是,Faro 默认不会对 <script> 标签资源的请求时间进行插桩,因此我们无法精确看到资源缓存对请求加载时间带来了多大改善。
| 指标 | 迁移前(Akamai) | 迁移后(CloudFront) |
|---|---|---|
| DNS | 56ms | 58ms |
| Connection | 47ms | 33ms |
| Cache | 4ms | 4ms |
| Request | 1056ms | 184ms |
| Waiting | 11ms | 22ms |
虽然无法在 Faro 中看到资源的请求耗时,但我们可以通过 CloudFront 按对象的缓存统计看到缓存的效果。筛选微前端资源后可以看到,边缘缓存命中率从 52% 上升到了 95%。两类可缓存资源均接近 100%:remoteEntry.js 从 63% 提升至 100%,不可变的内容哈希分块从 48% 提升至 100%。
| 指标 | 迁移前(Akamai) | 迁移后(CloudFront) |
|---|---|---|
| remoteEntry.js | 63% | 100% |
| 不可变分块 | 48% | 100% |
| 所有微前端请求 | 52% | 95% |
大部分提升来自消除了重新验证。此前由于没有显式缓存头,约 40% 的资源请求都是重新验证:CloudFront 虽然持有文件,但几乎每次请求都会回源校验(304 Not Modified)。为内容哈希分块设置 Cache-Control: immutable、为 remoteEntry.js 设置 s-maxage,将这些往返变成了真正的缓存命中。这既降低了回源带宽、节省了成本,也提升了性能。
Faro 会为每个会话打上对用户前一次会话的引用标记,这让我们能够区分首次(冷缓存)加载和回访(热缓存)加载。我们预期回访用户能从新的缓存策略中获益最多。从首次内容绘制来看,迁移前回访用户比新用户快约 17%,迁移后这一差距扩大到了约 29%。
| 指标 | 迁移前(Akamai) | 迁移后(CloudFront) |
|---|---|---|
| 新用户 | 2296ms | 1156ms |
| 回访用户 | 1912ms | 816ms |
在 LCP 上我们没有看到类似的对比变化,LCP 更多由应用渲染逻辑和数据获取接口调用决定,而非 JS 加载速度。在这项指标上,新用户和回访用户的提升幅度相同,均为约 38%。
下一步
在前端应用的性能方面,我们仍有很大的提升空间。Web Vitals 认为 p75 下“良好”的 LCP 应低于 2.5 秒,而我们目前约为 3.8 秒,仍有改进余地。
我们的架构旨在让分布式团队更快地开发功能,虽然 SPA 和微前端有助于实现这一目标,却是以性能为代价的。在平台层面,我们仍可在 index.html 缓存以及将骨架屏内联到 HTML 文件以优化 FCP 等方面做更多改进。归根结底,要进一步降低 LCP,还需要产品团队共同关注,优化从前端到后端逻辑的首屏渲染关键路径。
此外,引入缓存失效步骤也给部署流水线增加了一定的复杂性。CloudFront 缓存标签为我们组织缓存失效逻辑提供了灵活的方式,我们正考虑利用它来简化逻辑,并提升按需清除缓存资源的能力。
随机一篇博客
评论
登录后参与讨论