news 2026/10/30 2:19:03

BFF 层缓存设计:在 CDN 和 API 之间再加一层缓存

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
BFF 层缓存设计:在 CDN 和 API 之间再加一层缓存

BFF 层缓存设计:在 CDN 和 API 之间再加一层缓存

一、你已经做了 CDN 缓存和 API 缓存,但页面 TTFB 还是 800ms

一个典型的 Web 应用请求链路:浏览器 → CDN(缓存命中率 60%)→ Nginx 反向代理 → BFF(Backend 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 CacheEntry<T> { data: T; createdAt: number; expiresAt: number; etags: Record<string, string>; // 下游 API 的版本标识 } interface ApiSource { name: string; ttl: number; // 缓存时间(秒) fetch: () => Promise<{ data: any; etag: string }>; } class BFFCacheManager { private redis: Redis; private pendingRequests: Map<string, Promise<any>> = new Map(); constructor(redisUrl: string) { this.redis = new Redis(redisUrl, { maxRetriesPerRequest: 3, retryStrategy(times) { return Math.min(times * 100, 3000); }, }); } /** * 聚合请求 + 缓存 * * 设计决策:缓存键包含 API 版本的组合 hash。 * 不仅基于请求参数,还基于"数据源的当前状态"。 * 当任一数据源更新后 ETag 变化,缓存自动失效。 */ async getOrFetch<T>( cacheKey: string, sources: ApiSource[], ttl: number = 60, ): Promise<T> { // Step 1:尝试从 Redis 获取缓存 const cached = await this.redis.get(`bff:${cacheKey}`); if (cached) { const entry: CacheEntry<T> = 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.fetchAndCache<T>(cacheKey, sources, ttl); } /** * 回源获取 + 写入缓存 * * 防缓存击穿:pendingRequests 保证同一 cacheKey 的并发请求 * 只有一个会真正回源,其他的等待结果。 */ private async fetchAndCache<T>( cacheKey: string, sources: ApiSource[], ttl: number, ): Promise<T> { // 防击穿:检查是否有正在进行的相同请求 const pendingKey = `pending:${cacheKey}`; const pending = this.pendingRequests.get(pendingKey); if (pending) { return pending as Promise<T>; } const promise = this.doFetch(sources); this.pendingRequests.set(pendingKey, promise); try { const result = await promise; // 写入 Redis 缓存 const entry: CacheEntry<T> = { 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: Record<string, string> = {}; const aggregated: Record<string, 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: Record<string, string>, sources: ApiSource[], ): Promise<boolean> { // 简化实现:检查每个 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, ): Promise<void> { try { await this.fetchAndCache(cacheKey, sources, ttl); } catch (err) { // 后台刷新失败静默处理,不阻塞主流程 console.warn(`BFF cache refresh failed for ${cacheKey}:`, err); } } /** * 获取降级数据 */ private async getFallback(sourceName: string): Promise<any> { // 尝试从本地内存缓存读取上次成功的数据作为降级 return { _fallback: true, _source: sourceName }; } /** * 选择性缓存失效 * 当下游数据变更时,仅失效关联的缓存 */ async invalidateBySource(sourceName: string): Promise<void> { // 使用 Redis SCAN 查找所有包含该 source 的缓存键 // 生产环境建议使用 Redis 的 Pub/Sub + 索引表, // 而非 scan(scan 在大键量下很慢) 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: CacheEntry<any> = 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 思维,把"数据什么时候变了"作为失效触发点,而不是"缓存到了多少秒该过期了"。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/25 6:51:49

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

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

作者头像 李华
网站建设 2026/10/30 6:22:32

BatteryML实战指南:构建企业级电池寿命预测平台的深度剖析

BatteryML实战指南&#xff1a;构建企业级电池寿命预测平台的深度剖析 【免费下载链接】BatteryML 项目地址: https://gitcode.com/gh_mirrors/ba/BatteryML 在电动汽车和储能系统快速发展的时代&#xff0c;电池健康状态预测已成为关键技术瓶颈。BatteryML作为一个开源…

作者头像 李华
网站建设 2026/10/25 11:17:03

Windows微信批量消息发送工具:三步搞定群发难题的智能助手

Windows微信批量消息发送工具&#xff1a;三步搞定群发难题的智能助手 【免费下载链接】WeChat-mass-msg 微信自动发送信息&#xff0c;微信群发消息&#xff0c;Windows系统微信客户端&#xff08;PC端 项目地址: https://gitcode.com/gh_mirrors/we/WeChat-mass-msg 还…

作者头像 李华
网站建设 2026/10/30 6:24:17

STM32烧录方式全解析:从串口到SWD实战指南

1. STM32烧录方式全景解析作为一名在嵌入式领域摸爬滚打多年的工程师&#xff0c;我见过太多初学者在STM32烧录环节栽跟头。烧录方式的选择直接影响开发效率和调试体验&#xff0c;今天我就结合实战经验&#xff0c;系统梳理STM32的几种主流烧录方式及其应用场景。STM32作为ARM…

作者头像 李华
网站建设 2026/10/30 6:24:22

MySQL迁移Kingbase?一文告诉你有哪些语法兼容的坑

信创浪潮下&#xff0c;“MySQL & Oracle 兼容”是很多国产数据库厂商的标配宣传语&#xff0c;但兼容到什么程度、哪些细节会踩坑&#xff0c;往往只有真刀真枪的测过才知道。语法层面能跑通不代表业务逻辑能对齐&#xff0c;一个 REPLACE INTO 的主键校验差异&#xff0c…

作者头像 李华
网站建设 2026/10/20 9:40:35

Loritta快速入门:5分钟搭建你的第一个Discord机器人

Loritta快速入门&#xff1a;5分钟搭建你的第一个Discord机器人 【免费下载链接】Loritta &#x1f481; A multipurpose, multilanguage, customizable, modular, and very cute bot for Discord! ~Making your server more awesome~ 项目地址: https://gitcode.com/gh_mirr…

作者头像 李华