使用微前端进行缓存与 CDN
微前端(micro-frontend)架构中的缓存比单体前端更为复杂。系统包含一个壳应用、多个远程清单及其所引用的代码块,而它们各自的部署频率不同,对数据陈旧的容忍度也不同。本文介绍我们在 Mintel 的实践、过去遇到的问题,以及目前尚未完全解决的部分。
2026 年 6 月更新:我后来又写了一篇后续文章,介绍如何优化我们的微前端缓存策略,其中涵盖了我们为提升性能和降低带宽成本所做的改动。
我们的技术栈
我们使用 Webpack Module Federation 运行约 30 个微前端。壳应用是一个纯静态的 Jamstack 应用,部署到 S3,通过 CloudFront 提供服务,最外层则由 Akamai 置于所有服务之前。各远程应用位于固定且明确的 URL。
部署就是执行一条 rclone 命令,将构建后的 dist 目录复制到 S3 中每个 MFE 对应的特定子目录。壳应用和每个远程应用都是独立部署的,由不同团队分别维护。
我们如何配置各类资源
不同资源有不同的缓存要求。下面介绍我们目前的设置及其原因。
index.html - 不缓存
壳应用的 index.html 会引导加载所有内容。如果它是过期的,那么下游的所有内容都可能出错。我们通过 S3 对象元数据为它设置 Cache-Control: no-cache 标头,并在部署过程中自动完成这项设置。
no-cache 并不意味着文件不会被缓存,而是意味着 CDN 或浏览器在提供文件之前必须向源站重新验证。如果源站返回 304,就提供缓存副本;但如果内容发生了变化,则返回新的副本。
remoteEntry.js - 永不缓存
remoteEntry.js 是 Module Federation 清单。它告诉壳应用在哪里查找某个远程应用的代码块。当你部署远程应用时,这个文件会发生变化,但文件名不会变。
过期的 remoteEntry.js 有两种失败模式。显而易见的一种是报错:如果它指向上一次部署中的代码块,而这些代码块后来已经被替换,就会发生运行时错误。更隐蔽的一种是,在用户获取新的清单之前,他们看不到应用的新版本。这会导致远程应用团队已经发布了修复或新功能,但用户由于清单过期,仍然在运行旧代码。
在 Akamai 层,我们为 remoteEntry.js 设置 Cache-Control: no-store, max-age=0,以阻止浏览器缓存它。我们还通过 dynamic remote loading(动态远程加载),使用 module-federation-import-remote 包,默认向 remoteEntry.js URL 添加缓存清除查询参数。由于我们的 CloudFront 分配将查询字符串纳入缓存键,这样即使文件被 CloudFront 缓存,每次请求也会获得唯一 URL,从而绕过缓存并从 S3 获取最新版本。
代码块 - 不设置显式标头
remoteEntry.js 所引用的 JavaScript 代码块通过 Webpack 的 contenthash 替换机制,使用基于内容寻址(content-addressed)的方式命名。当文件内容发生变化时,哈希值会变化,文件名也会随之变化。这意味着你可以积极缓存代码块,因为新的部署会生成新的文件名,CDN 会自动将它们视为新资源。
在 output.filename 中进行配置很简单:
output: {
filename: '[name].[contenthash].js',
}我们曾遇到过团队忘记为远程应用的资源文件名配置 contenthash 的情况。代码块使用可预测的名称部署后被缓存,后续部署的内容在缓存 TTL 自然过期之前都不会反映给用户。
目前我们没有为代码块设置显式的 Cache-Control 标头。因此,CloudFront 会根据来自 S3 的 Last-Modified 或 ETag 标头,通过启发式 TTL 缓存这些文件。在 Akamai 层,我们对源站响应设置了 no-store 行为,因此所有缓存都发生在浏览器或 CloudFront 中。
404 处理
我们的壳应用是一个单页应用。路由在客户端处理。如果用户直接访问某个路由或刷新页面,CDN 会在 S3 中查找该路径下的文件,但找不到任何内容,默认会返回 404。
解决方法是将 CloudFront 配置为针对源站返回的 4xx 响应提供 index.html。AWS 在 CloudFront 分配设置中将其称为自定义错误响应。
Akamai 会将任何不匹配已知 API 或 MPA 路由的请求代理到 CloudFront,因此这种回退行为由 CloudFront 层处理,并适用于所有 MFE 路由。
用户请求流程
远程应用采用延迟加载方式,通过 React.lazy 和动态导入进行封装。只有当用户导航到需要某个远程应用的位置时,壳应用才会获取该远程应用的 remoteEntry.js。
CloudFront/S3 故障
上述设置是随着时间逐步演变的,部分原因是为了应对事故。2025 年 1 月,AWS 发生问题,导致我们的 CloudFront 源站开始无法从 S3 获取内容,并产生 404 NoSuchBucket 错误。由于 CloudFront 被配置为针对 S3 返回的 4xx 响应提供 index.html,这是标准的 SPA 全捕获设置,这些错误在到达 Akamai 之前被转换成了 200 响应。Akamai 无法得知发生了任何问题,于是照常缓存这些响应。AWS 恢复后,用户仍然会从 Akamai 边缘节点和自己的浏览器缓存中获取这些已缓存的错误响应。
在 Akamai 中清除缓存既慢又麻烦。你无法使用通配符指定路径并清除所有匹配某个模式的内容,而必须提供具体 URL。面对几十个 MFE 和数百个 JavaScript 代码块文件,这在压力之下并不现实。我们最终进入了一个应急作战室,匆忙提交一批清除请求;这些请求一直处于加载状态,而缓存则随着 TTL 自然过期,在几小时内逐渐失效。这也无法帮助浏览器中缓存了错误响应的用户。
我们最终采用的应急方案是修改关键 MFE 的 Webpack 配置中的 contenthash 长度,然后重新部署。修改 contenthash 长度会改变所有生成文件的文件名,迫使 CDN 将它们视为新资源,而不是提供缓存的错误响应。这个方法奏效了,但我们是在压力下想到它的,它并不是文档化运行手册中的步骤。
从那以后,我们禁用了 Akamai 对 MFE 资源的缓存,并为 S3 存储桶增加了多区域故障转移,以降低再次陷入相同处境的风险。我们还开始为 index.html 显式设置 no-cache,确保变更能够快速生效,并让任何错误的回退响应不会被长时间缓存。
对于“如果错误响应被缓存了,计划是什么”这个问题,诚实的答案仍然是:我们没有一个干净利落的解决方案。需要快速让所有内容失效时,修改 contenthash 长度、强制全面生成新文件名的办法仍然是我们的核选项。
改进我们的缓存策略
撰写本文让我们有机会反思当前缓存策略的运作方式。系统正常运行时,人们通常不会经常重新审视缓存配置,而目前的设置在实际运行中也一直足够可靠。
然而,我们的缓存配置分散在 index.html 的 S3 对象元数据和 remoteEntry.js 的 Akamai 规则中,其他资源完全没有显式缓存标头。没有一个统一的位置可以查看完整的缓存策略。出于对之前事故痛苦经历的强烈回应,我们在最外层的 Akamai 层完全不进行缓存,但这损害了性能并增加了带宽成本。
更清晰的方法是通过 S3 对象元数据,在源站为每种资源设置显式的 Cache-Control 标头,并将 CDN 层视为遵循源站标头的缓存,而不是定义缓存策略的地方。这也意味着,如果我们将来替换或重新配置 Akamai 层,缓存行为会由源站决定,而不会在不知不觉中丢失。
我们完全没有为代码块设置缓存标头,因此依赖 CDN 和浏览器的启发式规则来决定缓存时长。为经过内容哈希命名的代码块设置显式的 Cache-Control: max-age=31536000, immutable 标头,并重新启用 Akamai 对源站缓存行为的遵循,会是一个很好的改进,可以确保它们作为不可变资源被积极且正确地缓存;但在我们当前的设置中,无法保证每个团队都正确配置了构建输出文件名以使用 contenthash。不过,另一种方法可以同时解决这两个问题,但它需要进行更大规模的架构变更。
替代方案:带版本的 URL 和发现服务
以上所有内容都假设远程应用位于固定且明确的 URL。这个部署模型最简单,但它也是 remoteEntry.js 难以缓存的根本原因,因为当你原地修改一个文件时,就永远无法安全地长时间缓存它。
更稳健的方法是将版本信息包含在 URL 本身中:
https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js使用带版本的路径后,remoteEntry.js 就会像其他代码块一样成为基于内容寻址的文件。你可以使用 max-age=31536000, immutable 缓存它。旧版本会无限期保留在 S3 中,因此处于会话中的用户不会因部署而出错。回滚只需让清单指向之前的版本,而不必重新部署。
要实现这一点,壳应用不能将远程应用 URL 硬编码。你需要一个发现服务,也就是壳应用启动时调用的某个服务,用于获取每个远程应用当前的 URL:
{
"remote-a": "https://cdn.example.com/remote-a/v1.4.2/remoteEntry.js",
"remote-b": "https://cdn.example.com/remote-b/v2.1.0/remoteEntry.js"
}
不过,我们并没有这样做,尽管我们一年前就已经识别出这种模式,并将其作为未来可能的发展方向加入了架构蓝图。它可以发现团队遗漏 contenthash 配置的问题,但这是一个纪律问题,不足以成为迁移 30 个 MFE 的理由。在每个 CDN 层进行更积极的缓存也有助于提升性能并降低带宽成本,但如果没有对缓存命中率和带宽成本进行详细分析,就很难量化其影响;而使用当前方法为代码块设置不可变标头,也能达到类似效果。
发现服务还可以支持更复杂的部署模式,例如金丝雀发布或部署级别的功能开关,但这些都会增加复杂性和运维负担。在大多数情况下,我们能够直接在应用逻辑中使用功能开关,这样更容易判断用户正在运行的代码。金丝雀发布要真正发挥作用,也需要投入时间建立自动化监控和告警策略。
发现服务仍然是未来可能采用的选项,但我们当前更直接的行动重点,是在更简单的部署方式下改进现有缓存策略。
随机一篇博客