尧图网络 高端网站定制 · 原创设计
免费咨询热线
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/10/27 5:00:13
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/10/8 9:42:42
Android模糊效果终极指南:BlurView完整使用教程与性能优化

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

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

📅 2026/10/29 13:00:17
MORE NEWS

更多资讯

📰

电工证丢了怎么补考?官方报名入口在哪?别让证书耽误你找工作

电工证丢了怎么补考?官方报名入口在哪?别让证书耽误你找工作 你的电工证丢了,正在为补考发愁?急需特种作业证才能上岗找工作,偏偏证书找不到了,这可怎么办?别急,今天就从实际出发,手把手教你 电工证丢了怎么补考 ,并告诉你 官方报名入口…

📰

博罗电工证报考条件怎么考?通过率揭秘全在这里

博罗电工证报考条件怎么考?通过率揭秘全在这里 你还在为没电工证找不到工作发愁?特种作业证是很多行业的“敲门砖”,尤其是在建筑、电力、市政等领域,没有电工证连入职资格都没有。尤其是三门峡市政公用工程从业者,电工证几乎是标配。今天我来给你…

📰

章丘电工证报名考证千万别踩坑:怕考不过白交培训费?这样准备稳了

章丘电工证报名考证千万别踩坑:怕考不过白交培训费?这样准备稳了 怕考不过白交培训费?别再踩坑了,章丘电工证报名考证这事,很多人被培训机构忽悠了,结果花了钱还没拿到证。今天就来给你掰扯清楚,从报名到考试再到拿证,全流程怎么走才靠谱,不被坑。…

📰

保山考电工证网上查询,没证怎么找好工作?

保山考电工证网上查询,没证怎么找好工作? 你是不是也这样,一边在工地、工厂里干着电工活儿,一边心里发慌——这证还没考,万一查出来没证,就可能被辞退,连工资都拿不到?别急,今天就给你讲清楚保山考电工证网上查询的全流程,以及没有电工证在找工作时…

📰

重庆电工证发证机关全流程办理指南:工地太忙根本没时间复习考试?

重庆电工证发证机关全流程办理指南:工地太忙根本没时间复习考试? 工地太忙根本没时间复习考试?别急, 官方报名入口 已经帮你整理好, 重庆电工证发证机关 的流程、培训机构选择、证书变更、跨省转介全攻略都在下面,直接看干货,省时省力省心。…

📰

04年电工证怎么查询?官方报名入口全攻略,别再被中介忽悠了

04年电工证怎么查询?官方报名入口全攻略,别再被中介忽悠了 你是不是也遇到过这种情况:手头拿着2004年考的电工证,想找地方复审,结果被各种中介骗钱,甚至查不到自己的证?现在别急,我来手把手教你,怎么查证、怎么复审、怎么避免被坑,…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

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

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

📞 💬