当前位置: 首页 > news >正文

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 思维,把"数据什么时候变了"作为失效触发点,而不是"缓存到了多少秒该过期了"。

http://www.cnnetsun.cn/news/3526126.html

相关文章:

  • 5分钟掌握缠论分析:通达信插件让技术分析自动化
  • BatteryML实战指南:构建企业级电池寿命预测平台的深度剖析
  • Windows微信批量消息发送工具:三步搞定群发难题的智能助手
  • STM32烧录方式全解析:从串口到SWD实战指南
  • MySQL迁移Kingbase?一文告诉你有哪些语法兼容的坑
  • Loritta快速入门:5分钟搭建你的第一个Discord机器人
  • Injector命令行参数全攻略:从基础到高级用法,开发者必备清单
  • Windows本地部署Qwen2大模型|超详细步骤,附带全套避坑清单(新手零门槛)
  • 大模型AI搜索信源排名规则深度解析:豆包 vs DeepSeek 引用逻辑对比与GEO实战策略
  • AMD Qwen3.5-9B-w4a16-tao-symgroup-torchao-v0.17.0社区贡献指南:如何参与模型优化与改进
  • 如何为iOS 17设备解锁JIT编译能力:SideJITServer实战指南
  • DeepSeek LeetCode 3605. 数组的最小稳定性因子 Rust实现
  • 新手小白学C语言——排序算法(冒泡、选择、插入)
  • HTTP和HTTPS
  • AI会话越多越忙?搭建首个自改进业务代理团队的完整实践
  • 微信支付证书自动化管理:Java平台证书下载工具深度解析
  • Relative Rotation Graph(相对旋转图)|Highcharts创建RRG 图示列代码
  • Aperture多语言SDK使用教程:Go、Java、Python、JavaScript全攻略
  • Golang学习-sync.RWMutex 读写锁
  • 物理AI迎来“开悟时刻”:大晓发布开悟世界模型、以人为中心的环采方案2.0与三大行业解决方案
  • 炸裂!WhatsApp再诉NSO:一纸“永久禁令”为何沦为废纸?全球“合法监听”黑产链调查
  • 金额用 Long 还是 BigDecimal?
  • grunt-contrib-copy 配置文件详解:从基础配置到高级选项
  • 【限时解密】头部MCN私有数字人直播中台架构图:含ASR延迟<200ms的FPGA加速方案与商用授权清单
  • fluxsort基准测试:在不同数据集上的表现分析
  • KnpGaufretteBundle社区贡献指南:如何参与项目开发与维护
  • Bazel rules_foreign_cc快速入门:5分钟学会构建外部C/C++项目
  • Verilog飞行射击游戏设计与实现:从敌机生成到子弹碰撞
  • 程序员誓言Programmer‘s Oath:保护用户隐私的7个关键原则
  • kableExtra在企业级应用中的应用:构建自动化报表系统的完整方案