Node.js 独立产品缓存架构设计:多级缓存策略与一致性保障

Node.js 独立产品缓存架构设计:多级缓存策略与一致性保障

一、缓存的"多级博弈":内存、Redis 与 CDN 之间的数据一致性问题

缓存是独立产品性能优化中投入产出比最高的技术手段。一个合理的缓存架构可以让 API 响应时间从 200ms 下降到 10ms 级别,数据库查询量减少 90% 以上。但缓存引入的经典问题——数据一致性——同样不可忽视:用户在个人设置页修改了昵称,但首页的用户信息模块显示的仍然是旧昵称,这种体验比没有缓存时的慢加载更糟糕。

单级缓存(如仅使用 Redis)能解决读放大问题,但无法解决网络延迟问题——Redis 通常部署在另一台服务器上,跨网络的内存读写仍需要 0.5ms~2ms 的延迟。多级缓存的思路是在请求链路的不同位置设置多层缓存,越靠近请求源头缓存速度越快但容量越小,越靠近存储层缓存容量越大但速度越慢。

典型的三级缓存架构:

  • L1 — 进程内存缓存:最快(< 0.01ms),容量受限于进程内存(通常限制在 100MB 以内)。适合热点数据的极短周期缓存(1~60 秒)。
  • L2 — Redis 分布式缓存:中等速度(0.5~2ms),容量灵活。适合跨实例共享的数据缓存(1 分钟~1 小时)。
  • L3 — 数据库查询结果缓存:最慢(5~20ms),容量最大。适合复杂查询或低频访问的数据。

二、多级缓存的核心设计模式

2.1 Cache-Aside 模式(旁路缓存)

这是最基础的缓存模式,也是独立产品中最推荐使用的模式:

  • 读操作:先查缓存,命中则返回;未命中则查数据库,将结果写入缓存(L2 + L1),再返回。
  • 写操作:先写数据库,再删除缓存(而非更新缓存)。删除优于更新的原因在于:如果多个写操作并发,先写数据库再删除缓存可以保证最终一致性;而先更新缓存可能在并发场景下导致脏数据。

Cache-Aside 的局限性在于首次查询(缓存未命中)和缓存集体失效(缓存雪崩)时的性能。对抗这两个问题的策略分别是缓存预热(在应用启动时预加载热点数据到 L1 和 L2)和对不同 Key 设置随机的 TTL(如基础 TTL 3600 秒 + 随机 0~600 秒的浮动),避免大量 Key 在同一时刻过期。

2.2 主动失效与事件驱动

Cache-Aside 的被动失效(等 TTL 过期)在以下场景中会产生问题:用户修改了个人信息,该信息被缓存在 L1 和 L2 中,TTL 为 1 小时。用户刷新页面后,看到的是旧数据,直到缓存自然过期。

解决方案是主动失效——当数据发生变更时,发布一个缓存失效事件,由各服务实例监听并清除本地 L1 缓存。L2(Redis)可以直接通过 Key 删除。事件的传递媒介可以是 Redis Pub/Sub(简单可靠、零额外依赖)或消息队列(如 RabbitMQ,适合复杂的多服务场景)。

2.3 缓存穿透、击穿与雪崩的防御

  • 缓存穿透:查询一个不存在的数据(如数据库中没有该 ID 的记录),缓存始终未命中,每次查询都穿透到数据库。防御:布隆过滤器(Bloom Filter)预先判断 Key 是否存在;对不存在的 Key 也缓存空值(TTL 较短,如 60 秒)。
  • 缓存击穿:热点数据的 Key 在过期瞬间被大量并发请求访问,所有请求同时穿透到数据库。防御:互斥锁(Mutex Lock)——第一个请求获取锁并从数据库加载数据,其他请求等待锁释放后从缓存读取。
  • 缓存雪崩:大量缓存 Key 在同一时间段过期,或 Redis 服务宕机导致所有请求直达数据库。防御:随机 TTL、多级缓存降级(L1 兜底)、Redis 哨兵/集群保证高可用。

三、Node.js 多级缓存系统的实现

/** * Node.js 多级缓存系统 * 实现 L1(内存 LRU)+ L2(Redis)+ Cache-Aside + 穿透/击穿防御 */ import { EventEmitter } from 'events'; // ---- L1: 进程内存缓存(LRU) ---- interface LRUNode<T> { key: string; value: T; expireAt: number; prev: LRUNode<T> | null; next: LRUNode<T> | null; } class LRUCache<T> { private cache = new Map<string, LRUNode<T>>(); private head: LRUNode<T> | null = null; private tail: LRUNode<T> | null = null; private stats = { hits: 0, misses: 0, evictions: 0 }; constructor(private maxSize: number = 1000) {} get(key: string): T | undefined { const node = this.cache.get(key); if (!node) { this.stats.misses++; return undefined; } // 检查过期 if (Date.now() > node.expireAt) { this.remove(node); this.stats.misses++; return undefined; } this.stats.hits++; // 移动到头(最近使用) this.moveToHead(node); return node.value; } set(key: string, value: T, ttlMs: number): void { const expireAt = Date.now() + ttlMs; // 如果 Key 已存在,更新值 const existing = this.cache.get(key); if (existing) { existing.value = value; existing.expireAt = expireAt; this.moveToHead(existing); return; } const node: LRUNode<T> = { key, value, expireAt, prev: null, next: null, }; // 容量满时淘汰最久未使用的节点 if (this.cache.size >= this.maxSize) { this.evictLRU(); this.stats.evictions++; } this.cache.set(key, node); this.addToHead(node); } del(key: string): void { const node = this.cache.get(key); if (node) { this.remove(node); } } keys(): string[] { return [...this.cache.keys()]; } getSize(): number { return this.cache.size; } getStats(): { hits: number; misses: number; evictions: number; hitRate: number } { const total = this.stats.hits + this.stats.misses; return { ...this.stats, hitRate: total > 0 ? this.stats.hits / total : 0, }; } private addToHead(node: LRUNode<T>): void { node.next = this.head; node.prev = null; if (this.head) this.head.prev = node; this.head = node; if (!this.tail) this.tail = node; } private moveToHead(node: LRUNode<T>): void { if (node === this.head) return; this.remove(node); this.addToHead(node); } private remove(node: LRUNode<T>): void { if (node.prev) node.prev.next = node.next; if (node.next) node.next.prev = node.prev; if (node === this.head) this.head = node.next; if (node === this.tail) this.tail = node.prev; this.cache.delete(node.key); } private evictLRU(): void { if (this.tail) { this.cache.delete(this.tail.key); this.remove(this.tail); } } } // ---- L2: Redis 缓存适配器 ---- // 简化的 Redis 客户端接口(实际使用 ioredis 或 node-redis) interface RedisLike { get(key: string): Promise<string | null>; set(key: string, value: string, mode: 'EX', ttl: number): Promise<'OK'>; del(key: string): Promise<number>; setnx(key: string, value: string): Promise<number>; expire(key: string, seconds: number): Promise<number>; publish(channel: string, message: string): Promise<number>; subscribe(channel: string): Promise<void>; on(event: 'message', cb: (channel: string, message: string) => void): void; } class RedisCacheAdapter { constructor(private redis: RedisLike) {} async get<T>(key: string): Promise<T | null> { const raw = await this.redis.get(key); if (!raw) return null; try { return JSON.parse(raw) as T; } catch { return null; } } async set<T>(key: string, value: T, ttlSeconds: number): Promise<void> { await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds); } async del(key: string): Promise<void> { await this.redis.del(key); } /** * 分布式锁:防止缓存击穿 * 使用 SETNX(SET if Not eXists)实现 */ async acquireLock(key: string, ttlSeconds: number = 10): Promise<boolean> { const result = await this.redis.setnx(key, Date.now().toString()); if (result === 1) { await this.redis.expire(key, ttlSeconds); return true; } return false; } async releaseLock(key: string): Promise<void> { await this.redis.del(key); } } // ---- 多级缓存管理器 ---- interface MultiLevelCacheOptions { l1MaxSize?: number; l1DefaultTTLMs?: number; l2DefaultTTLSeconds?: number; /** 缓存 Key 前缀(命名空间隔离) */ keyPrefix?: string; /** 是否启用穿透保护(缓存空值) */ enableNullCache?: boolean; /** 空值缓存 TTL(秒) */ nullCacheTTL?: number; /** 锁等待超时(毫秒) */ lockWaitTimeoutMs?: number; } class MultiLevelCache extends EventEmitter { private l1: LRUCache<any>; private l2: RedisCacheAdapter; private options: Required<MultiLevelCacheOptions>; constructor( redis: RedisLike, options: MultiLevelCacheOptions = {} ) { super(); this.l1 = new LRUCache(options.l1MaxSize ?? 500); this.l2 = new RedisCacheAdapter(redis); this.options = { l1MaxSize: options.l1MaxSize ?? 500, l1DefaultTTLMs: options.l1DefaultTTLMs ?? 60_000, l2DefaultTTLSeconds: options.l2DefaultTTLSeconds ?? 300, keyPrefix: options.keyPrefix ?? 'cache:', enableNullCache: options.enableNullCache ?? true, nullCacheTTL: options.nullCacheTTL ?? 60, lockWaitTimeoutMs: options.lockWaitTimeoutMs ?? 5000, }; // 订阅 Redis 缓存失效频道 redis.on('message', (channel, message) => { if (channel === `${this.options.keyPrefix}invalidation`) { try { const key = JSON.parse(message).key; this.l1.del(key); } catch { // 忽略解析错误 } } }); } /** * 读取缓存(先 L1 → L2 → DB) */ async get<T>( key: string, dbLoader: () => Promise<T | null>, options?: { ttlMs?: number; ttlSeconds?: number } ): Promise<T | null> { const fullKey = this.options.keyPrefix + key; const l1TTL = options?.ttlMs ?? this.options.l1DefaultTTLMs; const l2TTL = options?.ttlSeconds ?? this.options.l2DefaultTTLSeconds; // L1 查询 const l1Result = this.l1.get(fullKey); if (l1Result !== undefined) { // undefined 表示缓存了"不存在"的值(穿透保护) return l1Result === '__NULL__' ? null : l1Result; } // L2 查询 const l2Result = await this.l2.get<T | '__NULL__'>(fullKey); if (l2Result !== null) { // 回填 L1 if (l2Result === '__NULL__') { this.l1.set(fullKey, '__NULL__', l1TTL); } else { this.l1.set(fullKey, l2Result, l1TTL); } return l2Result === '__NULL__' ? null : l2Result as T; } // L1 和 L2 都未命中,尝试获取锁后查询数据库(防止击穿) const lockKey = `${fullKey}:lock`; const hasLock = await this.l2.acquireLock(lockKey, 10); if (hasLock) { try { // 持有锁的请求负责查询数据库并填充缓存 const dbResult = await dbLoader(); if (dbResult !== null && dbResult !== undefined) { // 写入 L2 和 L1 await this.l2.set(fullKey, dbResult, l2TTL); this.l1.set(fullKey, dbResult, l1TTL); return dbResult; } else { // 穿透保护:缓存空值 if (this.options.enableNullCache) { await this.l2.set(fullKey, '__NULL__', this.options.nullCacheTTL); this.l1.set(fullKey, '__NULL__', Math.min(l1TTL, this.options.nullCacheTTL * 1000)); } return null; } } finally { await this.l2.releaseLock(lockKey); } } else { // 未获取锁的请求等待 return this.waitForCache<T>(fullKey, lockKey, dbLoader, l1TTL, l2TTL); } } /** * 写入缓存并主动失效 * Cache-Aside 写策略:先写数据库 → 再删除缓存 */ async invalidate(key: string): Promise<void> { const fullKey = this.options.keyPrefix + key; // 删除 L2 await this.l2.del(fullKey); // 删除本地 L1 this.l1.del(fullKey); // 广播到其他实例,通知它们清理 L1 try { // 使用 Redis Pub/Sub 广播失效事件 // 注意:此处仅示意,实际需要在构造函数中订阅频道 } catch { // 广播失败不影响主流程 } } /** * 设置缓存(绕过数据库加载) */ async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> { const fullKey = this.options.keyPrefix + key; const l2TTL = ttlSeconds ?? this.options.l2DefaultTTLSeconds; await this.l2.set(fullKey, value, l2TTL); this.l1.set(fullKey, value, l2TTL * 1000); } /** * 等待其他请求填充缓存(击穿保护中的等待逻辑) */ private async waitForCache<T>( key: string, lockKey: string, dbLoader: () => Promise<T | null>, l1TTL: number, l2TTL: number ): Promise<T | null> { const startTime = Date.now(); const pollInterval = 100; // 轮询间隔 while (Date.now() - startTime < this.options.lockWaitTimeoutMs) { // 检查缓存是否已被持有锁的请求填充 const result = await this.l2.get<T | '__NULL__'>(key); if (result !== null) { if (result === '__NULL__') { this.l1.set(key, '__NULL__', l1TTL); return null; } this.l1.set(key, result, l1TTL); return result as T; } // 等待一段时间后重试 await new Promise((resolve) => setTimeout(resolve, pollInterval)); } // 超时:直接查询数据库(降级策略) try { return await dbLoader(); } catch { return null; } } /** * 获取 L1 统计 */ getL1Stats(): ReturnType<LRUCache<any>['getStats']> { return this.l1.getStats(); } /** * 清除指定前缀的所有缓存 */ async clearByPrefix(prefix: string): Promise<void> { const fullPrefix = this.options.keyPrefix + prefix; // 清除 L1 中匹配的 key for (const key of this.l1.keys()) { if (key.startsWith(fullPrefix)) { this.l1.del(key); } } // L2 需要 SCAN + DEL(生产代码中实现) } } // ---- 导出 ---- export { MultiLevelCache, LRUCache, RedisCacheAdapter }; export type { MultiLevelCacheOptions, RedisLike };

缓存命中的监控

多级缓存系统的效果需要通过命中率数据来量化。应持续监控的热点指标:

  • L1 命中率:如果持续低于 60%,说明热点数据没有被合理识别——检查 L1 容量是否太小或 TTL 是否太短。
  • L2 命中率:如果持续低于 70%,说明 TTL 设置太短或缓存预热不足。
  • 锁等待次数与超时:高锁等待次数表明存在热点 Key 的频繁击穿,需要调整 TTL 或引入永不过期 + 异步更新的策略。
  • 缓存穿透的空值比例:如果空值缓存命中率超过 30%,需要检查是否存在大量无效的查询请求(可能是爬虫或异常流量)。

四、缓存一致性的复杂度边界

4.1 最终一致性 vs 强一致性

多级缓存架构天然倾向最终一致性——L1、L2 和数据库之间存在一个短暂的不一致窗口(通常为数百毫秒到数秒)。对于用户个人信息、文章列表等场景,最终一致性完全可接受。对于余额、库存、订单状态等数据,需要更严格的方案:

  • 不缓存余额/库存类数据:这类数据的正确性优先级高于性能,每次直接查数据库。
  • 如果必须缓存:使用数据库的变更数据捕获(CDC,如 Debezium)实时同步到缓存,将不一致窗口压缩到毫秒级。
  • 乐观锁 + 版本号:在缓存和数据库中同时存储数据版本号,读取时校验版本一致性。

4.2 多实例 L1 同步的开销

当产品从单实例扩展到多实例时,L1 缓存的一致性问题从无到有。实例 A 修改了用户数据并删除了本地 L1,但实例 B 的 L1 仍然持有旧数据。解决方案的本质选择:

  • 主动广播(如上述代码中的 Pub/Sub):实现复杂度低,但每个失效事件需要所有实例处理。当实例数超过 10 个时,广播风暴可能成为瓶颈。
  • 被动同步:L1 的 TTL 设置得非常短(如 5 秒),依赖自然过期达成最终一致。简单可靠,但会略微降低 L1 命中率。
  • 粘性会话(Sticky Session):负载均衡将同一用户的请求始终路由到同一实例,降低跨实例不一致的触发概率。与主动广播配合使用效果最佳。

五、总结

多级缓存是独立产品性能优化的核心技术,三级架构(进程内存 → Redis → 数据库)在绝大多数场景下是最优选择。L1 负责极低延迟的热点读取,L2 提供跨实例共享和中等延迟访问,L3 作为最终的持久化保障。Cache-Aside 写策略和分布式锁(SETNX)防击穿是两个必选机制,缓存穿透保护(空值缓存)和 Pub/Sub 主动失效是强烈推荐的增强项。

落地的分步策略:第一步实现 L2(Redis)+ Cache-Aside,保证读操作的缓存加速;第二步引入 L1(LRU 内存缓存),将热点数据的读取延迟压缩到微秒级;第三步完善失效策略——主动失效 + TTL 随机浮动 + 空值缓存——形成一个抗击穿、防雪崩、反穿透的完整缓存体系。每个步骤完成后,通过命中率指标验证效果,并根据实际数据模式调整 TTL 参数。缓存架构的设计没有"一劳永逸"的方案,而是在持续的运行监控中动态优化配置参数。