Node.js 缓存策略工具链:多级缓存的失效传播与热点数据的工程实践
Node.js 缓存策略工具链多级缓存的失效传播与热点数据的工程实践一、缓存里的数据和数据库里的不一样——缓存一致性的经典困境缓存设计的核心悖论你引入缓存是为了提速但缓存本身引入了新的问题——数据一致性。当你更新了数据库中的一条记录所有缓存了这条数据的层都必须感知到这次更新。如果失效传播做得不好用户就会看到过期数据甚至基于过期数据做出错误操作。在 Node.js 应用中一个典型的缓存架构包含三层浏览器缓存Cache-Control/ETag、CDN 缓存边缘节点、应用层缓存Redis/内存。每一层都有自己的过期策略和更新机制它们之间的失效传播构成了一个链条——链上任何一环断裂都会导致数据不一致。二、多级缓存的失效传播机制主动失效 vs 被动过期graph TD A[数据更新] -- B[应用层失效] B -- C[Redis 删除/更新缓存] C -- D[发送 Cache Invalidation 事件] D -- E[其他应用实例br/监听事件清理本地缓存] D -- F[CDN 刷新 APIbr/清除边缘缓存] E -- G[内存缓存br/LRU Map] H[Cache-Control: max-age] -- I[浏览器缓存br/自然过期] F -- J[CDN 边缘节点br/下次请求回源] subgraph 主动失效 B C D E F end subgraph 被动过期 H I J end两种失效策略主动失效数据更新时立即删除或更新上游的所有缓存。这是保证一致性的手段。被动过期设置 TTL到期后自然失效。这是防止缓存雪崩的安全网。主动失效是理想态但总有失效不到的场景如网络故障导致的事件丢失。被动过期的 TTL 是一个兜底上限——最坏情况下数据在 X 秒后一定会刷新。三、多级缓存的完整工程实现缓存管理器——统一的多级缓存协调// cache/cache-manager.ts — 多级缓存协调器 import { EventEmitter } from events; interface CacheLayer { name: string; getT(key: string): PromiseT | null; setT(key: string, value: T, ttlSeconds: number): Promisevoid; delete(key: string): Promisevoid; has(key: string): Promiseboolean; } export class CacheManager extends EventEmitter { private layers: CacheLayer[] []; private stats { hits: 0, misses: 0, sets: 0, invalidations: 0, }; constructor(private defaultTTLSeconds: number 300) { super(); } // 注册缓存层从近到远内存 → Redis → CDN addLayer(layer: CacheLayer): this { this.layers.push(layer); return this; } // 多级读取从最近的一层开始逐层回填 async getT(key: string): PromiseT | null { for (let i 0; i this.layers.length; i) { const value await this.layers[i].getT(key); if (value ! null) { this.stats.hits; // 回填更近的层eg: Redis 命中 → 回填到内存 for (let j 0; j i; j) { await this.layers[j].set(key, value, this.defaultTTLSeconds).catch(() {}); } return value; } } this.stats.misses; return null; } // 多级写入所有层同时写入 async setT(key: string, value: T, ttlSeconds?: number): Promisevoid { const ttl ttlSeconds ?? this.defaultTTLSeconds; await Promise.all( this.layers.map(layer layer.set(key, value, ttl).catch(err { console.error(Cache layer ${layer.name} set failed:, err); })) ); this.stats.sets; } // 失效传播从近到远逐层删除 async invalidate(key: string): Promisevoid { await Promise.all( this.layers.map(layer layer.delete(key).catch(err { console.error(Cache layer ${layer.name} delete failed:, err); })) ); this.stats.invalidations; // 广播失效事件给其他实例 this.emit(invalidate, { key, timestamp: Date.now() }); } // 批量失效基于模式匹配如 user:123:* async invalidatePattern(pattern: string): Promisevoid { // 对于 Redis 层使用 SCAN DEL for (const layer of this.layers) { if (deletePattern in layer) { await (layer as any).deletePattern(pattern).catch(() {}); } } this.emit(invalidate-pattern, { pattern, timestamp: Date.now() }); } getStats() { return { ...this.stats }; } }内存缓存层——基于 LRU Map 的进程内缓存// cache/layers/memory-cache.ts export class MemoryCacheLayer implements CacheLayer { name memory; private store: Mapstring, CacheEntryunknown; constructor(private maxSize: number 1000) { this.store new Map(); } async getT(key: string): PromiseT | null { const entry this.store.get(key); if (!entry) return null; if (Date.now() entry.expiresAt) { this.store.delete(key); return null; } // LRU访问时移到末尾 this.store.delete(key); this.store.set(key, entry); return entry.value as T; } async setT(key: string, value: T, ttlSeconds: number): Promisevoid { // 淘汰策略超过最大数量时删除最旧的 if (this.store.size this.maxSize) { const oldest this.store.keys().next().value; if (oldest) this.store.delete(oldest); } this.store.set(key, { value, expiresAt: Date.now() ttlSeconds * 1000, }); } async delete(key: string): Promisevoid { this.store.delete(key); } async has(key: string): Promiseboolean { const entry this.store.get(key); if (!entry) return false; if (Date.now() entry.expiresAt) { this.store.delete(key); return false; } return true; } } interface CacheEntryT { value: T; expiresAt: number; }Redis 缓存层——支持模式失效// cache/layers/redis-cache.ts import { Redis } from ioredis; export class RedisCacheLayer implements CacheLayer { name redis; constructor(private redis: Redis, private keyPrefix: string cache:) {} async getT(key: string): PromiseT | null { const raw await this.redis.get(this.prefix(key)); if (!raw) return null; try { return JSON.parse(raw) as T; } catch { return null; } } async setT(key: string, value: T, ttlSeconds: number): Promisevoid { await this.redis.setex( this.prefix(key), ttlSeconds, JSON.stringify(value), ); } async delete(key: string): Promisevoid { await this.redis.del(this.prefix(key)); } async has(key: string): Promiseboolean { return (await this.redis.exists(this.prefix(key))) 1; } // 模式匹配删除这是 Redis 独有的优势 async deletePattern(pattern: string): Promisevoid { const fullPattern this.prefix(pattern); let cursor 0; let keysDeleted 0; do { const [newCursor, keys] await this.redis.scan( cursor, MATCH, fullPattern, COUNT, 100 ); cursor newCursor; if (keys.length 0) { const deleted await this.redis.del(...keys); keysDeleted deleted; } } while (cursor ! 0); console.log(Redis: deleted ${keysDeleted} keys matching ${fullPattern}); } private prefix(key: string): string { return ${this.keyPrefix}${key}; } }缓存旁路模式——Read-Through Write-Invalidate// cache/cache-aside.ts — 缓存旁路模式的完整封装 export class CacheAsideT { constructor( private cache: CacheManager, private fetcher: (key: string) PromiseT | null, private namespace: string, private ttlSeconds: number 300, ) {} private cacheKey(id: string): string { return ${this.namespace}:${id}; } async get(id: string): PromiseT | null { const key this.cacheKey(id); // 1. 尝试从缓存读取 const cached await this.cache.getT(key); if (cached ! null) { return cached; } // 2. 缓存未命中从数据源加载 const data await this.fetcher(id); if (data null) { // 缓存空结果以防止缓存穿透短 TTL await this.cache.set(key, null as any, 60); return null; } // 3. 写入缓存 await this.cache.set(key, data, this.ttlSeconds); return data; } // 批量读取——处理热点数据 async getBatch(ids: string[]): PromiseMapstring, T | null { const result new Mapstring, T | null(); const missedIds: string[] []; // 1. 批量从缓存读取 for (const id of ids) { const cached await this.cache.getT(this.cacheKey(id)); if (cached ! null) { result.set(id, cached); } else { missedIds.push(id); } } // 2. 批量回源 if (missedIds.length 0) { const fetched await Promise.all( missedIds.map(async id { const data await this.fetcher(id); if (data ! null) { await this.cache.set(this.cacheKey(id), data, this.ttlSeconds); } return { id, data }; }) ); for (const { id, data } of fetched) { result.set(id, data); } } return result; } // 失效机制——数据更新后调用 async invalidate(id: string): Promisevoid { await this.cache.invalidate(this.cacheKey(id)); } // 批量失效 async invalidateAll(ids: string[]): Promisevoid { await Promise.all(ids.map(id this.invalidate(id))); } }防缓存击穿——对于热点数据的单飞模式Singleflight// cache/singleflight.ts — 防止缓存击穿 export class Singleflight { private inFlight new Mapstring, Promiseunknown(); async doT(key: string, fn: () PromiseT): PromiseT { // 如果已经有进行中的请求复用其 Promise const existing this.inFlight.get(key); if (existing) { return existing as PromiseT; } const promise fn().finally(() { this.inFlight.delete(key); }); this.inFlight.set(key, promise); return promise; } } // 在 CacheAside 中集成 export class CacheAsideWithSingleflightT extends CacheAsideT { private singleflight new Singleflight(); async get(id: string): PromiseT | null { const key this.cacheKey(id); const cached await this.cache.getT(key); if (cached ! null) return cached; // 使用 singleflight 确保只有一个请求回源 return this.singleflight.do(key, async () { // 双重检查可能在前一个请求完成后已经写入了缓存 const recheck await this.cache.getT(key); if (recheck ! null) return recheck; const data await (this as any).fetcher(id); if (data ! null) { await this.cache.set(key, data, (this as any).ttlSeconds); } return data; }); } }四、多级缓存的边界缓存雪崩、热点数据与最终一致性缓存雪崩大量缓存在同一时刻过期导致所有请求打向数据库。缓解措施包括对 TTL 添加随机抖动300s ± 30s、使用互斥锁防止并发回源上文 Singleflight 模式、以及设置永不过期的热缓存 后台异步刷新Write-Behind 模式。热点数据少数 key 承载了极高的访问频率。这些 key 的失效会引起大量请求穿透到数据库。除了 Singleflight 防击穿外另一种策略是永不过期 逻辑过期——缓存中数据不设 TTL但在数据旁边存储一个逻辑过期时间后台任务定期检查并刷新。读请求检查到逻辑过期时返回当前缓存值并异步触发刷新。最终一致性当多级缓存失效时所有层的最终一致性依赖 TTL。这个 TTL 的选择是一个权衡——越短一致性问题越小但缓存命中率越低越长性能越好但数据不一致的时间窗口越长。对于关键的业务数据如用户余额缓存 TTL 应设为 5-30 秒对于非敏感的展示数据如文章详情可以设为 300-600 秒。五、总结Node.js 多级缓存的核心挑战不是如何存储数据而是如何保证多级缓存之间数据的一致性。通过统一的 CacheManager 协调内存、Redis 等多层缓存搭配主动失效invalidate和被动过期TTL可以实现可靠的多级缓存体系。落地方案从单层 Redis 缓存开始在CacheAside模式中封装读写逻辑。当需要更低的延迟时在 Redis 之上增加内存缓存层。Singleflight防击穿和TTL Random Jitter防雪崩是两个投入产出比最高的缓存保护策略。多级缓存的价值不在于层级数量而在于每一层都有清晰的职责和失效策略——如果某一层的行为和上一层完全相同那它就是冗余的。