首页 / 文章

Node.js中的缓存风暴:为何Redis缓存会令数据库不堪重负

了解为何在热门键过期时,简单的 Redis 缓存旁路配置会导致负载同步到数据库,以及锁机制和后台刷新如何避免这一问题。

发布者:REACTAPP.TOP阅读约 7 分钟1648 词

来源: “I Thought Redis Would Make My API Faster.” It Didn’t.

在响应缓慢的查询前加入 Redis 是应对数据库压力时的标准解决方案,平时看来确实效果显著。但在流量高峰期,简单的缓存旁路机制反而可能使情况急剧恶化:一旦某个热门键过期,所有并发请求会同时失败,进而共同给数据库带来巨大压力。本文解释了这种被称为“缓存雪崩”的故障现象,说明了它如何在 Node.js API 中级联扩散,并展示了两种防止该问题的方法:分布式重生成锁与后台预计算。

在流量高峰前有效的解决方案

常见的做法是采用缓存旁路模式。每次请求时,API 首先查询 Redis;若未找到则向数据库查询并将结果存回 Redis 以供后续使用。在流量较低的时段部署该方案,能立即降低延迟。

周一早晨的高峰时段到来后,API停止响应。Node.js进程的CPU使用率较低,因此事件循环并非问题所在。然而主数据库的CPU使用率却高达100%。

Redis并非瓶颈

Redis本身很少成为问题所在。它能够以亚毫秒级的延迟处理数十万次操作/秒。真正造成问题的其实是应用程序与缓存之间的交互方式,尤其是当数据缺失时应用程序的表现。

一种常见的思维模型是将系统比作一座安静的图书馆:你向前台(Redis)询问,如果书籍不在那里,图书管理员(API)就会从书架(PostgreSQL)中取来并放在前台。但在大规模应用中,这一场景更像是交易大厅。当数千个客户端在缓存条目过期瞬间同时请求同一款热门商品时,它们会同时发现前台空无一人,于是都转向书架去查找。

挤兑现象的成因

以GET /products/trending这样的接口为例,生成其响应需要在Node.js中执行多次连接、子查询以及一些聚合操作,耗时约1.2秒。该结果会被缓存十分钟。

在稳定状态下,这种机制效率极高:在一万次请求中,只有一次会访问数据库,其余请求都在大约5毫秒内从Redis中获取响应。

假设10分钟的TTL在09:00:00结束时到期。09:00:01时,有50个请求同时到达。这些请求全部失败,且每个请求都会发起同样耗时1.2秒的查询。数据库瞬间要并行处理50个繁重的查询,连接池几乎立刻被耗尽,此后所有需要访问数据库的API请求,哪怕是查询无关数据,都不得不排队等待这50个请求处理完毕。

需要明确的是:等待池化连接并不会阻塞Node.js事件循环,因为这些等待是异步的。实际上,请求会以待处理的承诺和已打开的套接字形式堆积起来,这就是为什么在用户看到超时错误的同时,进程看起来却处于空闲状态。

这就是所谓的缓存雪崩效应。缓存并没有减轻负载,反而将突发的负载集中到了某一时刻。

为何会持续恶化而非恢复

系统崩溃并非只是处于缓慢状态,而是会自我加剧。

  • 查询时间急剧增加。原本1.2秒的查询,当有50个请求同时争夺CPU、锁资源及I/O时,可能会延长到25秒。
  • API实例在等待过程中会保持数千个开放的HTTP连接,从而消耗大量内存和文件描述符。
  • API层的CPU使用率始终处于较低水平,因此基于CPU阈值自动扩缩容的功能可能永远不会增加资源;而新增API实例只会向数据库发送更多查询。
  • 由于慢查询尚未将结果写回,新的请求不断到达却无法处理。最终数据库会因内存耗尽或连接超时而彻底崩溃。

防止系统崩溃

增加更快的存储并不能解决本质上的协调问题。真正的解决办法是改变谁有权限以及何时重新生成昂贵数据。

模式1:分布式重生成锁

当API以多个实例运行时,需要在所有实例之间协调重生成操作,这就需要使用存储在Redis中的锁。第一个未找到缓存的请求会在查询数据库之前尝试获取该锁。而在锁被占用期间其他未找到缓存的请求则会避开数据库,选择以下两种方式之一处理:

  • 等待并重试:短暂休眠,例如50毫秒,然后再次检查Redis,直到出现新值。
  • 使用过时数据:如果系统能够容忍略微陈旧的结果,则返回之前的值。

下面的示例使用了 ioredis。它首先读取缓存。如果未找到数据,则尝试使用 NX 和 PX 5000 执行 SET 操作,这样只有在没有其他进程持有锁的情况下才会创建锁,并且该锁会在五秒后过期。获胜的进程会执行查询,将结果存储600秒,然后在 finally 块中释放锁;其他进程则会持续轮询,直到该位置被填充数据。

// Example: Using Redis for a locking/syncing pattern (Conceptual)
import { Redis } from 'ioredis';
const redis = new Redis();


async function getCachedTrendingProducts() {
  const cacheKey = 'products:trending';
  const lockKey = 'lock:products:trending';
  // Attempt 1: Get data
  let data = await redis.get(cacheKey);
  if (data) return JSON.parse(data);
  // Attempt 2: Cache miss. Try to acquire the regeneration lock.
  const acquiredLock = await redis.set(lockKey, 'locked', 'NX', 'PX', 5000); // 5s lock
  if (acquiredLock) {
    // We hold the lock. We are the ONLY worker hitting the DB.
    try {
      // 1.2s Database Operation
      const dbData = await fetchTrendingProductsFromDB();
      // Store in Redis (10 min TTL)
      await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
      return dbData;
    } finally {
      // Release lock
      await redis.del(lockKey);
    }
  } else {
    // We did NOT get the lock. Someone else is already regenerating.
    // Wait and Retry (e.g., exponential backoff or simple polling)
    return pollRedisUntilPopulated(cacheKey);
  }
}

其效果是,无论有多少请求同时到达,每个键最多只会有一个耗时的查询能够访问数据库。

在实际生产环境中,还有几处改进值得考虑:

  • 锁的过期时间必须超过最坏情况下的查询耗时。如果重新生成数据所需的时间超过五秒,锁就会失效,于是另一个进程会开始重复执行查询。
  • 无条件删除锁可能会移除在您的锁过期后被其他工作进程获取的锁。将唯一令牌作为锁值,并仅在该令牌仍匹配时才进行删除,通常可通过简单的 Lua 脚本实现这一目的,从而避免上述问题。
  • pollRedisUntilPopulated 需要设置超时时间并准备备用方案,否则如果锁持有者进程崩溃,等待请求可能会挂起。
  • 在同一个 Node.js 进程中,可以通过为每个键共享一个正在处理的承诺对象来合并并发的锁获取失败情况,这样在涉及 Redis 之前就能减少锁尝试的次数。
  • 模式 2:在后台预计算

    最稳健的方法是将数据传输与生成过程分开。请求处理程序通过同步路径仅从缓存中读取数据,而从不重新构建它。如果某个值缺失,它们会立即返回空状态或“加载中”状态,这远比占用连接数秒要好得多。

    数据重新生成则由后台进程负责,比如定时任务、BullMQ工作线程或变更数据捕获消费者,在键过期前对其进行刷新。在下面的示例中,每九分钟就会有一个工作线程重新生成热门列表,并以相同的十分钟有效时间将其写回,因此在正常运行情况下该键永远不会失效。

    // Separate background worker file
    // For example, running every 9 minutes via Cron or BullMQ
    async function regenerateTrendingProductsCache() {
      const cacheKey = 'products:trending';
      try {
        // Proactive DB fetch (1.2s)
        const dbData = await fetchTrendingProductsFromDB();
        // Refresh the 10-minute cache
        await redis.set(cacheKey, JSON.stringify(dbData), 'EX', 600);
        console.log('Cache proactively refreshed.');
      } catch (err) {
        console.error('Proactive refresh failed:', err);
      }
    }
    

    由于传入的请求总能找到已缓存的键,因此它们不会同时无法通过缓存而直接查询数据库。有两个措施可确保安全:其一是在单个调度器上执行刷新操作,或通过锁机制防止多个实例同时刷新;其二是在刷新失败时发出警报,因为仅靠catch块只能记录错误信息,而该键仍会在一分钟后过期。关于调度方面的内容,使用BullMQ和Redis构建可靠的后台任务系统这篇指南有更详细的介绍。

    两者之间的选择

    锁定机制保持了熟悉的缓存旁路处理流程,适用于那些请求时间不可预测的数据。而后台预计算则适合用于少量已知、处理成本高且频繁被读取的键,比如热门列表或控制面板摘要,这样能获得最稳定的延迟表现。在TTL值中加入少量随机抖动,也能在大量键同时被写入、否则会一同过期的情况下起到辅助作用。

    核心要点

    • Redis速度很快;性能问题源于应用程序对缓存未命中情况的处理方式,而非缓存本身。
    • 简单的缓存旁路机制会将单次过期事件转化为对最慢依赖组件的同步访问高峰,且故障可能被误认为是API CPU使用率低所致。
    • 分布式锁可确保只有单个调用者重新生成键;需谨慎处理锁的过期、所有权以及等待超时问题。
  • 背景刷新会完全消除请求路径中的重新生成机制,是处理高热度、高成本键值的最佳方案。
  • 应将缓存视为在负载条件下保护数据库的架构决策,而非单纯的提速手段。