BFF 层缓存设计:在 CDN 和 API 之间再加一层缓存
BFF 层缓存设计在 CDN 和 API 之间再加一层缓存一、你已经做了 CDN 缓存和 API 缓存但页面 TTFB 还是 800ms一个典型的 Web 应用请求链路浏览器 → CDN缓存命中率 60%→ Nginx 反向代理 → BFFBackend For Frontend→ 下游微服务多个。CDN 缓存了静态资源微服务端也做了 Redis 缓存。但页面 TTFB首字节时间还是高——瓶颈就在 BFF。BFF 层的职责是聚合多个下游 API 的数据按照前端需要的格式拼装后返回。这个聚合操作本身就耗时等待最慢的 API 返回通常是 2-3 个 API 中最慢的那个然后做数据拼接、格式转换。如果这层不做缓存每个页面请求都要重复一次完整的聚合链路。但在 BFF 层加缓存有独特的挑战BFF 缓存的是聚合后的数据涉及多个数据源的时效性和一致性问题。BFF 不能简单地缓存 5 分钟——一个数据源更新了其他数据源没更新缓存需要做到部分失效。二、底层机制与原理剖析BFF 缓存的难点不在于怎么存而在于什么时候失效。传统的 TTL 缓藏在这一层基本不可用——下游数据源的更新频率各不相同TTL 要么设太短没效果要么设太长数据过期。为此系统采用了一套动态的请求与失效流程页面请求到达后BFF 优先检查缓存命中则直接返回响应若未命中则并行请求下游 API包括用户信息、商品列表及推荐内容。待各 API 响应并携带 ETag 或 Version 字段后BFF 聚合数据、计算缓存键并写入缓存。在失效策略上系统摒弃了被动等待过期转而监听下游 API 变更事件。一旦数据源发生变更消息队列广播事件BFF 据此执行选择性失效仅清除受影响数据源如 user:xxx 或 product:xxx关联的缓存随后再次进入缓存检查流程。核心设计是三个方面缓存键设计BFF 缓存的键不只是一个 URL path而是API 响应版本的组合 hash。下游每个 API 返回数据时可以附带一个 ETag 或 version 字段。BFF 把这些 ETag 收集起来构成缓存键。当任一 API 的 ETag 变化时缓存键变触发重新聚合。选择性失效不依赖 TTL 做失效改用事件驱动。当下游数据变更时如用户修改了昵称通过消息队列广播变更事件。BFF 对应的缓存失效接口收到事件后只失效该数据源关联的缓存。分层 TTL对不同数据源设置不同的 TTL。用户信息 30 秒商品列表 5 分钟推荐内容 1 小时。聚合后的缓存取最小值作为 expire或者允许不同数据源分别过期——用后台异步刷新。三、生产级代码实现/** * BFF 缓存层 * 核心设计 * 1. 缓存键 下游 API 版本组合 hash解决多数据源一致性 * 2. 事件驱动的选择性失效替代纯 TTL * 3. 防缓存击穿并发同一缓存键只允许一个请求回源 */ import { createHash } from crypto; import Redis from ioredis; interface CacheEntryT { data: T; createdAt: number; expiresAt: number; etags: Recordstring, string; // 下游 API 的版本标识 } interface ApiSource { name: string; ttl: number; // 缓存时间秒 fetch: () Promise{ data: any; etag: string }; } class BFFCacheManager { private redis: Redis; private pendingRequests: Mapstring, Promiseany new Map(); constructor(redisUrl: string) { this.redis new Redis(redisUrl, { maxRetriesPerRequest: 3, retryStrategy(times) { return Math.min(times * 100, 3000); }, }); } /** * 聚合请求 缓存 * * 设计决策缓存键包含 API 版本的组合 hash。 * 不仅基于请求参数还基于数据源的当前状态。 * 当任一数据源更新后 ETag 变化缓存自动失效。 */ async getOrFetchT( cacheKey: string, sources: ApiSource[], ttl: number 60, ): PromiseT { // Step 1尝试从 Redis 获取缓存 const cached await this.redis.get(bff:${cacheKey}); if (cached) { const entry: CacheEntryT JSON.parse(cached); // 检查各数据源是否过期 const allEtagsValid await this.checkEtags(entry.etags, sources); if (allEtagsValid Date.now() / 1000 entry.expiresAt) { return entry.data; } // 某个数据源过期但缓存整体还有效 - 后台异步刷新 if (Date.now() / 1000 entry.expiresAt) { this.refreshInBackground(cacheKey, sources, ttl).catch(() {}); return entry.data; // 返回旧数据不阻塞当前请求 } } // Step 2缓存未命中或已过期 - 回源 return this.fetchAndCacheT(cacheKey, sources, ttl); } /** * 回源获取 写入缓存 * * 防缓存击穿pendingRequests 保证同一 cacheKey 的并发请求 * 只有一个会真正回源其他的等待结果。 */ private async fetchAndCacheT( cacheKey: string, sources: ApiSource[], ttl: number, ): PromiseT { // 防击穿检查是否有正在进行的相同请求 const pendingKey pending:${cacheKey}; const pending this.pendingRequests.get(pendingKey); if (pending) { return pending as PromiseT; } const promise this.doFetch(sources); this.pendingRequests.set(pendingKey, promise); try { const result await promise; // 写入 Redis 缓存 const entry: CacheEntryT { data: result.data, createdAt: Date.now() / 1000, expiresAt: Date.now() / 1000 ttl, etags: result.etags, }; await this.redis.setex( bff:${cacheKey}, ttl 60, // Redis TTL 稍长于逻辑 TTL留缓冲 JSON.stringify(entry), ); return result.data; } finally { this.pendingRequests.delete(pendingKey); } } /** * 执行下游 API 调用和数据聚合 */ private async doFetch(sources: ApiSource[]) { // 并行请求所有下游 API const results await Promise.allSettled( sources.map(s s.fetch()) ); const etags: Recordstring, string {}; const aggregated: Recordstring, any {}; for (let i 0; i sources.length; i) { const result results[i]; const source sources[i]; if (result.status fulfilled) { aggregated[source.name] result.value.data; etags[source.name] result.value.etag; } else { // 单个 API 失败不阻塞整体用上次缓存的数据兜底 const fallback await this.getFallback(source.name); aggregated[source.name] fallback; etags[source.name] stale; } } return { data: aggregated as any, etags }; } /** * 检查各数据源的 ETag 是否仍然有效 */ private async checkEtags( cachedEtags: Recordstring, string, sources: ApiSource[], ): Promiseboolean { // 简化实现检查每个 source 是否有 ETag 变化 // 生产环境应通过轻量级的 HEAD 请求获取 ETag而非完整 GET for (const source of sources) { const cachedEtag cachedEtags[source.name]; if (!cachedEtag || cachedEtag stale) { return false; } } return true; } /** * 后台异步刷新缓存 */ private async refreshInBackground( cacheKey: string, sources: ApiSource[], ttl: number, ): Promisevoid { try { await this.fetchAndCache(cacheKey, sources, ttl); } catch (err) { // 后台刷新失败静默处理不阻塞主流程 console.warn(BFF cache refresh failed for ${cacheKey}:, err); } } /** * 获取降级数据 */ private async getFallback(sourceName: string): Promiseany { // 尝试从本地内存缓存读取上次成功的数据作为降级 return { _fallback: true, _source: sourceName }; } /** * 选择性缓存失效 * 当下游数据变更时仅失效关联的缓存 */ async invalidateBySource(sourceName: string): Promisevoid { // 使用 Redis SCAN 查找所有包含该 source 的缓存键 // 生产环境建议使用 Redis 的 Pub/Sub 索引表 // 而非 scanscan 在大键量下很慢 let cursor 0; const keysToDelete: string[] []; do { const [newCursor, keys] await this.redis.scan( cursor, MATCH, bff:*, COUNT, 100, ); cursor newCursor; for (const key of keys) { const cached await this.redis.get(key); if (cached) { try { const entry: CacheEntryany JSON.parse(cached); // 该缓存包含此数据源且标记非 stale - 需要失效 if (entry.etags[sourceName] entry.etags[sourceName] ! stale) { keysToDelete.push(key); } } catch { keysToDelete.push(key); // 格式异常也删除 } } } } while (cursor ! 0); if (keysToDelete.length 0) { await this.redis.del(...keysToDelete); } } }四、边界分析与架构权衡BFF 缓存的复杂性代价引入了多数据源的 ETag 管理这意味着下游 API 需要有 ETag 或版本接口支持。如果下游 API 不支持BFF 就只能退化为纯 TTL 缓存——而这种退化版本的问题前面已经说了。另一个问题是事件驱动的失效依赖消息队列的可靠性。如果缓存失效消息丢失缓存中的数据就永远不会更新。内存与 Redis 的权衡BFF 通常是 Node.js 进程如果缓存太大如缓存了 1000 页面的聚合数据Node.js 的堆内存会被撑爆。这就是为什么方案中使用了 Redis 而非本地内存。但引入 Redis 也增加了一个网络依赖——BFF 到 Redis 的延迟通常在 1-5ms。适用边界最适合有 3 个以上下游数据源、聚合逻辑复杂的 BFF 层。也适合下游数据源更新频率不同但前端要求强一致性的场景。前端是 SPA 结构、通过 BFF 做 SSR 或 BFF 聚合的场景是典型受益者。禁用场景不适合只有 1-2 个简单 API 调用的 BFF微服务网关层缓存收益不大。也不适合对实时性要求极高的场景如实时竞价、毫秒级行情缓存引入的旧数据风险高于收益。五、总结BFF 缓存的核心不是把数据存起来而是解决多数据源的一致性。ETag 组合缓存键让缓存对下游数据变更敏感、选择性失效避免一锅端的全清、防止缓存击穿保证回源可控。关键是放弃纯 TTL 思维把数据什么时候变了作为失效触发点而不是缓存到了多少秒该过期了。

相关新闻

5分钟掌握缠论分析:通达信插件让技术分析自动化

5分钟掌握缠论分析:通达信插件让技术分析自动化

5分钟掌握缠论分析:通达信插件让技术分析自动化 【免费下载链接】ChanlunX 缠中说禅炒股缠论可视化插件 项目地址: https://gitcode.com/gh_mirrors/ch/ChanlunX 还在为复杂的缠论分析头疼吗?ChanlunX缠论分析工具作为一款专业的通达信插件&#…

2026/10/15 2:58:20 阅读更多 →
力扣287-寻找重复数

力扣287-寻找重复数

287. 寻找重复数 - 力扣(LeetCode) 给定一个包含 n 1 个整数的数组 nums ,其数字都在 [1, n] 范围内(包括 1 和 n),可知至少存在一个重复的整数。 假设 nums 只有 一个重复的整数 ,返回 这个…

2026/10/2 3:56:18 阅读更多 →
哪个品牌的智慧食堂系统好用

哪个品牌的智慧食堂系统好用

究竟哪家的智慧食堂系统更适合你?在信息化快速发展的今天,智慧食堂管理系统已经成为各大企业、高校等食堂管理的必备工具。各大品牌如云勤通、阿里云、腾讯云及一些其他行业内的企业,纷纷推出了智慧食堂管理系统。这些系统各有特点&#xff0…

2026/9/24 18:42:47 阅读更多 →

最新新闻

Map 中删除一个 key,它的内存会释放么?

Map 中删除一个 key,它的内存会释放么?

在 Go 语言开发中,我们频繁使用内置函数 delete(m, key) 来删除哈希表中的元素。许多开发者直觉上会认为:既然删除了 key,对应的内存就应该被操作系统或 Go 垃圾回收器(GC)回收。然而事实并非如此:调用 del…

2026/10/15 4:03:46 阅读更多 →
编程猫、核桃、小码王、童程童美、学而思,到底哪家好?一次说清!

编程猫、核桃、小码王、童程童美、学而思,到底哪家好?一次说清!

编程猫、核桃编程、小码王、童程童美、学而思编程,这五个名字几乎覆盖了当下家长圈里讨论度较高的少儿编程选择。但机构多不等于好选,每家适配的年龄段、上课形式和培养目标都不一样。与其反复纠结,不如把它们的课程链路、师资配置和赛考出口摊开来看。本文从六大维度出发,逐家…

2026/10/15 4:03:46 阅读更多 →
技术路线要让评审一眼看懂逻辑:把开题报告的步骤串成可自证的推进关系

技术路线要让评审一眼看懂逻辑:把开题报告的步骤串成可自证的推进关系

技术路线被反复追问,大多不是图示不够漂亮,而是步骤之间缺少可追溯的因果关系。把每一步的产出约定为下一步的输入,再补上取舍依据与可行性论证,路线本身就能把逻辑讲清楚。下面按五个衔接层次拆解写法,并给出可直接套…

2026/10/15 4:03:46 阅读更多 →
收到秋招offer但还想等更好的,怎么办?

收到秋招offer但还想等更好的,怎么办?

其实很多同学拿到offer后,焦虑不会自动消失。因为会开始纠结: 这个 offer 要不要接?还有几家公司在流程中,要不要等?HR 催签,但自己还没想清楚。薪资还可以,但岗位不是最喜欢。…… 这种纠结不是…

2026/10/15 4:03:46 阅读更多 →
插件加载失败?拆解 did not activate 报错背后的排查与修复

插件加载失败?拆解 did not activate 报错背后的排查与修复

如果你在终端里看到类似这样的输出:failed to load plugins web boot: 2 entries did not activate linxin666/dsh-p先别急着抄起键盘骂框架,这个报错我能看懂,它是插件加载器在启动阶段给你的一份“体检报告”。plugins这个词在工程化体系里…

2026/10/15 4:03:46 阅读更多 →
git 源代码里,出现一大堆红色文件的一次思考

git 源代码里,出现一大堆红色文件的一次思考

git 源代码里,出现一大堆红色文件 是因为切换分支的时候,底座导致的。 1.首先要理解rebase是什么意思? rebase是录制本地代码,把最新的代码作为背景,然后把最新修改加在最新代码后面。 2.如何解决呢? git c…

2026/10/15 4:02:46 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/14 3:30:28 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/14 16:30:29 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/14 3:29:58 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 7:53:34 阅读更多 →