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