尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
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 思维把数据什么时候变了作为失效触发点而不是缓存到了多少秒该过期了。
RELATED

相关推荐

从LangChain到Autogen再到Claude-3.5 Sonnet Agent:AI Agent演进史上的3次范式跃迁(含性能衰减曲线图谱)

从LangChain到Autogen再到Claude-3.5 Sonnet Agent:AI Agent演进史上的3次范式跃迁(含性能衰减曲线图谱)

更多请点击: https://codechina.net 第一章:AI Agent 是什么 AI Agent(人工智能代理)是一种具备感知、决策与行动能力的自主软件实体,它能基于环境输入、目标约束和内部模型,持续规划并执行任务&#xff0…

📅 2026/9/12 10:28:52
5个理由告诉你:为什么这款免费开源卡拉OK游戏值得你立即尝试

5个理由告诉你:为什么这款免费开源卡拉OK游戏值得你立即尝试

5个理由告诉你:为什么这款免费开源卡拉OK游戏值得你立即尝试 【免费下载链接】USDX The free and open source karaoke singing game UltraStar Deluxe, inspired by Sony SingStar™ 项目地址: https://gitcode.com/gh_mirrors/us/USDX 还在为寻找完美的家庭…

📅 2026/8/23 17:31:05
Android模糊效果终极指南:BlurView完整使用教程与性能优化

Android模糊效果终极指南:BlurView完整使用教程与性能优化

Android模糊效果终极指南:BlurView完整使用教程与性能优化 【免费下载链接】BlurView Android blur view 项目地址: https://gitcode.com/gh_mirrors/blu/BlurView Android模糊效果是现代移动应用UI设计中的重要元素,能够显著提升用户体验和界面质…

📅 2026/8/23 17:31:07
MORE NEWS

更多资讯

📰

程序调试中的信号提示与处理技术详解

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

📰

Java Stream性能优化:JDK 8到JDK 17的进化与实战

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

📰

Redis布隆过滤器原理与实战应用详解

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

📰

农业AI成熟度检测系统:YOLO多版本调度与SpringBoot工程化实践

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

📰

把“杂七杂八”变成知识资产:个人知识管理的新思路

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

📰

蠢萌的小姐姐都能学会的Linux基本命令,有这份学习秘籍,你还不抓紧时间上车?

目录说明# /bin[重点]:是Binary的缩写,这个目录存放着最经常使用的命令# /sbin[重点]:是Super User的意思,这里存放的是系统管理员使用的系统管理程序# /home[重点]:存放普通用户的主目录,在Linux中每个用户…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬