首页 / 文章

在 Node.js 中保持 Redis 缓存数据的一致性:实现无效化而不产生过期数据读取

比较 Node.js 中 Redis 的 TTL、写时删除、发布/订阅以及版本化键失效机制,分析每种机制可能带来的竞态条件,并构建一个故障时自动切换的缓存封装层。

发布者:REACTAPP.TOP阅读约 16 分钟3730 词

来源: Redis Caching in Node.js, Part 2: Invalidation That Doesn’t Lie

想象这样一种情况:用户上传了新的个人资料照片,收到成功响应后重新加载页面,却仍然看到旧的照片。他们再次尝试,结果依旧如此,几分钟后新照片才自动显示出来。后续提交的缺陷报告几乎无法复现,因为Redis仍在返回数据库早已替换过的数据副本。速度慢的数据库至少能给出正确结果;而快速缓存却返回错误数据,这种情况比没有缓存还要糟糕。本指南重点介绍Node.js中Redis缓存的写入流程:四种使条目失效的方法、每种方法的适用场景及其成本、每种方法可能引发的竞态条件、一个结合了读取路径保护与失效功能的可重用cacheAside()封装函数,以及最常导致过时数据持续存在的错误原因。

缓存中的读取相关内容,即旁路加载、防止挤兑问题以及热键过期时的处理方式,是另一个独立的话题;我们在Redis缓存基础、常见陷阱及面试相关概念一文中已对这些内容进行了概述。上述技术均无法防止写入操作后出现过时数据,而这正是本文其余部分要探讨的内容。

四种失效策略

以下策略大致按照从最简单到最复杂的顺序列出,供您参考。

策略1:仅依赖TTL

为每个缓存条目设置过期时间,接受该值可能已存在相应秒数的事实,并将该数值记录在产品团队能够查看的位置。

对于远超大多数团队预期的数据量而言,这是正确的解决方案。产品目录内容、分类层级结构、个人资料文本以及每小时汇率几乎无需做到毫秒级实时更新。即便数据延迟五分钟,300秒的过期时间就已足够满足需求:

await redis.set(`product:${id}`, JSON.stringify(product), 'EX', 300);

个人资料照片则情况不同。进行修改的人希望立即看到变化,而单纯的过期时间无法保证这一点。对于用户编辑的数据,必须在写入操作发生时立即使其失效。

策略2:在写入时删除对应条目

更新数据库后,移除相应的缓存键。下次读取时若未找到该数据,则会加载新行并重新填充缓存:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);

  return updated;
}

有两个细节确保了这一方法的正确性。

选择删除而非覆盖。既然你已经持有更新后的值,用SET命令将其写入Redis似乎是一种简单的优化方式。但当存在两个并发写入操作时,问题就出现了:它们的SET命令到达Redis的顺序可能与在数据库中的提交顺序相反,从而导致较早写入的数据被缓存下来,直到过期时间到来。而删除操作与顺序无关;无论哪个命令最后到达,结果都是键被清空。

先写入数据库再删除。改变操作顺序可以缩小问题发生的窗口:

// Wrong: delete before write
await redis.del(`user:${id}`);                     // 1. cache is empty
const updated = await db.users.update(id, patch);  // 2. write the row

// Between 1 and 2, a reader can miss the cache, read the OLD row,
// and put it back. The cache now holds the old value until its TTL.

在删除和更新之间,任何未命中缓存的读取操作都会加载旧数据并将其重新写回。

先写入数据库虽然不能完全消除问题,但能缩小问题发生的窗口。以下时间线展示了即使读取速度较慢,仍可能恢复过时数据的情况:

Reader                              Writer
------                              ------
GET user:42        -> miss
SELECT user 42     -> old row
                                    UPDATE user 42 (commit)
                                    DEL user:42
SET user:42 <- old row

读取操作在写入操作提交之前就获取了该行数据,然后在写入操作的删除操作之后才将其存储。那个旧值会一直被缓存,直到其有效时间结束。这种情况需要一个速度较慢的读取器以及恰好在错误时机执行删除操作的写入器,因此较为罕见;但如果是针对每分钟被读取数千次的键值,这种罕见情况也可能每天都会出现。

标准的解决办法是再执行一次删除操作,其调度时间要比正常处理所需的时间稍长一些:

async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  const key = `user:${id}`;
  await redis.del(key);
  // Catches a reader that loaded the old row before the commit.
  setTimeout(() => redis.del(key).catch(() => {}), 500).unref();
  return updated;
}

unref()调用可以防止在系统关闭时仍有待处理的定时器使进程保持打开状态,而空的catch块则能确保删除操作失败时不会产生未处理的异常。

还有两点需要注意:

  • 事务处理。如果更新操作在数据库事务中执行,应在提交成功后、即事务回调之外再执行删除操作。如果在提交之前就进行删除,将会重新产生上文所述的错误顺序。
  • 程序崩溃。如果在提交和删除之间进程终止,那么失效标记就会丢失,只有时间到限时才会自动修正。当无法承受失效标记丢失的情况时,应通过变更数据捕获机制来触发失效操作,例如在 Postgres 或 MySQL 上使用 Debezium,或在 MongoDB 上使用变更流功能。这样,失效操作的触发就依赖于事务的提交本身,而非请求处理程序能否持续运行足够长的时间。

策略 3:将失效标记广播到本地内存缓存

当所有进程都直接从共享的 Redis 中读取数据时,一个 DEL 操作会同时被所有进程看到,而发布/订阅机制并不会带来任何额外效果。

只有当进程在 Redis 前面添加一层小型内存层,如 Map 或 lru-cache,以避免对高频访问的键进行网络往返传输时,这种机制才变得必要。当实例 A 从 Redis 中删除某个键时,实例 B 的本地副本不会受到影响。通过发布该已失效的键,可以让每个实例都丢弃自己的副本:

// Every instance runs this at startup.
// A subscribed connection can't run other commands, hence duplicate().
const sub = redis.duplicate();
await sub.subscribe('cache:invalidate');
sub.on('message', (_channel, key) => localCache.delete(key));

// Whoever writes to the database:
async function updateUser(id, patch) {
  const updated = await db.users.update(id, patch);
  await redis.del(`user:${id}`);
  await redis.publish('cache:invalidate', `user:${id}`);

  return updated;
}

注意 duplicate() 调用:处于订阅模式的 Redis 连接无法发送普通命令,因此订阅功能需要独立的连接。

如果使用的是 Redis 6 或更高版本,在自行实现缓存功能之前,建议先了解其内置的客户端缓存支持(CLIENT TRACKING)。服务器会记住某个连接曾获取过的键,当这些键中的任意一个被修改时,就会通知该连接。不同客户端库的实现程度各异,因此请确认所使用的库确实支持此功能。

策略 4:带版本号的键

当返回旧值会引发正确性问题而非仅是外观上的问题时,可在键中嵌入版本号:

async function getOrder(id) {
  const version = (await redis.get(`order:${id}:ver`)) ?? '0';
  const key = `order:${id}:v${version}`;

const cached = await redis.get(key);
  if (cached) return JSON.parse(cached);
  const order = await db.orders.findById(id);
  if (!order) return null;

  await redis.set(key, JSON.stringify(order), 'EX', 86400);

  return order;
}

async function updateOrder(id, patch) {
  const updated = await db.orders.update(id, patch);
  await redis.incr(`order:${id}:ver`);

  return updated;
}

读取操作会从计数键中获取当前版本号,然后据此构建数据键。写入操作则只需递增该计数器即可。

按照此方案再次运行策略2的竞赛。慢速读取者会获取到过时的数据行;与此同时,写入者会提交操作并增加计数器值;随后读取者会将获取到的数据缓存起来。其写入操作会指向order:42:v0这个键,而不会有任何读取者再查询该键,因此过时的副本会一直存在于这个被废弃的键中直至过期。正是这种安全性让你付出了更高的复杂度代价。

确保其安全的规则如下:

  • 只有写入者可以操作计数器,且只能使用INCR指令。如果读取者用类似SET指令将计数器初始化为0,就可能会覆盖刚刚完成的递增操作,从而使之前的版本重新生效。
  • 保持计数器有效。不要设置过期时间,或者将过期时间设定得远长于数据条目的有效期。一旦计数器过期,它会重置为零,这样之前剩余的 v0 或 v1 条目又会重新显示出来。
  • 其代价是每次读取需要两次往返操作而非一次,此外还需为旧版本数据保留内存直到它们过期。对于订单、余额、库存水平以及任何与计费相关的内容,这种代价是合理的;但对于大多数其他数据而言则显得过度。

    选择策略

    简要对比:

    • 仅使用TTL:实现简单;数据最多可能有一个TTL周期的旧度;当可以接受一定程度的数据过时且能明确指定TTL值时使用。
    • 写入时删除:复杂度低;数据会立即更新,仅有极少数情况下会出现持续到TTL结束的竞争条件;适用于用户可编辑的所有内容,默认采用此策略。
  • 发布/订阅模式:复杂度中等;只要消息持续到达,内存中的副本就会保持最新状态;在 Redis 前添加本地缓存时需要使用该模式。
  • 版本化键:复杂度较高;过时的重建数据永远不会被读取;仅适用于那些旧值会导致金额错误或库存错误的数据。
  • 对于普通服务,结合过期机制与写入时删除功能已足够,此外还可以为被频繁读取且用户可修改的键设置延迟删除功能。只有在引入进程内缓存时才需要添加发布/订阅模式,而对于那些错误答案会带来财务损失的少数实体,则应使用版本化键。

    参考:CacheAside() 封装类

    由于缓存逻辑分散在各个请求处理函数中,这些规则很容易被遗忘,因此最好将逻辑编写一次并导入到所有地方。下面的模块使用了 ioredis,并整合了以下功能:

    • 对于无法直接访问 Redis 而不得不转而查询数据库的情况,采用“失败即开放”策略而非直接拒绝请求;
    • 采用“在验证期间仍使用旧值”机制,因此在刷新操作进行时仍会返回稍显过时的数据;
    • 每个进程仅支持单次操作,同一进程内的多个并发请求需共享相同的处理负载;
    • 通过跨进程锁结合比较删除的方式解除锁定,避免所有实例都重新构建同一个键;
    • 通过发布/订阅机制进行数据失效处理,并在稍后执行第二次删除操作。
    // cache.js
    const Redis = require('ioredis');
    const { randomUUID } = require('node:crypto');
    const logger = require('./logger');
    
    // Fail fast, so a dead Redis makes requests slower instead of hanging them.
    const redis = new Redis(process.env.REDIS_URL, {
      commandTimeout: 250,
      maxRetriesPerRequest: 1,
    });
    
    // A subscribed connection can't run other commands, so it gets its own,
    // with default options so it keeps reconnecting.
    const sub = new Redis(process.env.REDIS_URL);
    const CHANNEL = 'cache:invalidate';
    
    // Compare-and-delete, as in Part 1: never release a lock you no longer own.
    const UNLOCK = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      end
      return 0
    `;
    
    // ---- Invalidation fan-out to in-process caches ----
    const listeners = new Set();
    sub.subscribe(CHANNEL).catch(err => logger.error({ err }, 'cache: subscribe failed'));
    sub.on('message', (_channel, key) => {
      for (const fn of listeners) {
        try { fn(key); } catch (err) { logger.warn({ err, key }, 'cache: listener failed'); }
      }
    });
    
    function onInvalidate(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    }
    
    // ---- Fail-open reads and writes ----
    async function readEntry(key) {
      const raw = await redis.get(key).catch(() => null);
      if (!raw) return null;
      try { return JSON.parse(raw); } catch { return null; }
    }
    
    async function writeEntry(key, value, { freshSec, staleSec }) {
      const now = Date.now();
      const entry = {
        value,
        freshUntil: now + freshSec * 1000,
        staleUntil: now + (freshSec + staleSec) * 1000,
      };
    
      await redis.set(key, JSON.stringify(entry), 'EX', freshSec + staleSec).catch(() => {});
      return value;
    }
    
    // ---- Single-flight: per process first, then across processes ----
    const inflight = new Map();
    
    function once(slot, fn) {
      const existing = inflight.get(slot);
      if (existing) return existing;
      const p = fn().finally(() => inflight.delete(slot));
      inflight.set(slot, p);
    
      return p;
    }
    
    async function rebuild(key, loader, opts, { wait }, attempt = 0) {
      const lockKey = `lock:${key}`;
      const token = randomUUID();
    
      // If Redis is down the SET rejects. Treat that as "got the lock" and just load.
      const got = await redis.set(lockKey, token, 'NX', 'EX', opts.lockSec).catch(() => 'OK');
      if (got === 'OK') {
        try {
          // Another instance may have finished a rebuild while we were getting the lock.
          const entry = await readEntry(key);
          if (entry && Date.now() < entry.freshUntil) return entry.value;
          return await writeEntry(key, await loader(), opts);
        } finally {
          await redis.eval(UNLOCK, 1, lockKey, token).catch(() => {});
        }
      }
    
      // Someone else holds the lock. A background refresh can leave the work to them.
      if (!wait) return undefined;
      await new Promise(r => setTimeout(r, 50));
      const entry = await readEntry(key);
      if (entry && Date.now() < entry.freshUntil) return entry.value;
      if (attempt >= 20) return loader(); // about a second of waiting: do it ourselves
    
      return rebuild(key, loader, opts, { wait }, attempt + 1);
    }
    
    // ---- Public API ----
    async function cacheAside(key, loader, { freshSec = 60, staleSec = 300, lockSec = 5 } = {}) {
      const opts = { freshSec, staleSec, lockSec };
      const entry = await readEntry(key);
      const now = Date.now();
      if (entry && now < entry.freshUntil) return entry.value;
      if (entry && now < entry.staleUntil) {
        // Stale but servable: return it now, refresh once in the background.
        once(`refresh:${key}`, () => rebuild(key, loader, opts, { wait: false }))
          .catch(err => logger.warn({ err, key }, 'cache: refresh failed'));
        return entry.value;
      }
    
      // Cold, or past staleUntil: the caller waits for a load.
      return once(`load:${key}`, () => rebuild(key, loader, opts, { wait: true }));
    }
    
    async function invalidate(key, { redeleteMs = 500 } = {}) {
      await redis.del(key).catch(() => {});
      await redis.publish(CHANNEL, key).catch(() => {});
    
      // Second delete catches a rebuild that read the old row before the write committed.
      setTimeout(() => redis.del(key).catch(() => {}), redeleteMs).unref();
    }
    module.exports = { cacheAside, invalidate, onInvalidate, redis };
    

    在应用程序代码中使用该功能时,读取路径和写入路径各需一次调用:

    const { cacheAside, invalidate } = require('./cache');
    const user = await cacheAside(`user:${id}`, () => db.users.findById(id), {
      freshSec: 60,
      staleSec: 300,
    });
    
    async function updateUser(id, patch) {
      const updated = await db.users.update(id, patch);
      await invalidate(`user:${id}`);
      return updated;
    }
    

    该封装类中的若干设计选择并不直观,因此在采用之前请仔细阅读相关说明。

    为刷新操作和加载操作设置独立的单次操作时段

    后台刷新与加载操作使用不同的前缀标识,分别为 refresh: 和 load:。当某个后台刷新操作在获取跨进程锁时失败,它会故意返回 undefined,因为此时有其他实例正在处理重建任务。而那些确实需要使用该位置的共享值的调用者也会得到空值。

    获取锁后的第二次缓存读取

    一旦 rebuild() 成功获取锁,它会再次读取相关数据。在等待期间可能有其他进程已完成重建工作,此时重复执行加载操作只会浪费查询资源。

    获取锁时的有限等待时间

    无法获取锁定的调用方会以50毫秒的间隔尝试最多20次,大约持续一秒钟,之后才会自行执行加载器。与锁定持有者卡住时调用方队列永远无法清空的情况相比,出现少量重复查询反而更好。

    故障自动解锁机制

    如果Redis不可用且SET NX命令被拒绝,.catch机制会将该拒绝响应转换为'OK',这样所有调用方都会继续执行加载器。这虽能保持服务运行,但也意味着在Redis恢复之前所有负载都会由数据库承担。需确认数据库具备相应能力。

    默认参数的调整

    • lockSec的值应大于加载器的p99延迟时间,以避免在重建过程中锁定过期。
    • redeleteMs的值也应高于最慢的加载器耗时。单个索引查询适合500毫秒的时间设置;而需要四秒钟才能完成的仪表板聚合操作则需要更长的时间。

    刻意省略的内容

    未包含概率性提前过期功能。Stale-while-revalidate机制已为频繁读取的键去除了过期临界点,若将两者合并到同一数据结构中,则会增加理解刷新时机的复杂性。对于重建成本变化较大的键,可通过概率检测替代cacheAside()中的freshUntil比较操作。

    各相关功能在代码中的位置

    • 未命中、加载与存储:cacheAside()和rebuild()
    • Stale-while-revalidate机制:每个数据条目中的freshUntil和staleUntil字段
    • 进程内单次执行机制:once()
    • 跨进程锁定与安全解锁:rebuild()以及UNLOCK Lua脚本
  • 故障开放模式:每次Redis调用都使用.catch()处理异常,同时设置commandTimeout
  • 写入时先删除数据,稍后再次删除:使用invalidate()
  • 使本地缓存失效:通过invalidate()和onInvalidate()实现
  • 导致数据过时或损坏的常见问题

    这些问题的排序大致依据其可能造成的损害程度。

    将Redis错误视为致命错误

    如果在Redis不可用时直接使用await调用,就会立即抛出异常:

    const cached = await redis.get(key); // throws when Redis is unreachable
    

    缓存是一种优化手段。系统故障只应导致延迟,而不应引发大量500错误响应。最简单的解决办法是将失败情况转化为缓存未命中:

    const cached = await redis.get(key).catch(() => null);
    

    在 ioredis 的默认设置下,仅使用 .catch 是不够的。当连接断开时,客户端会缓存命令并不断重试,默认情况下每个请求最多重试 20 次,而非立即失败,这会导致请求一直等待已不可用的服务器。像该封装库的客户端那样配置 commandTimeout 以及较小的 maxRetriesPerRequest 值,可以促使它们尽快向加载器发起请求。即便在故障期间,每个请求仍会花费几百毫秒的时间,对于短暂的中断来说这是可以接受的。而在长时间中断的情况下,通过电路断路器在连续出现若干次错误后停止调用 Redis,便能挽回这部分时间。

    无 TTL 的失效处理

    删除操作可能会失效:提交后进程崩溃,部署过程中请求被中断,或者在订阅者正在重启时广播消息送达。如果没有设置TTL,这些情况都会导致Redis中永久存在过期的键。应为每个键设定过期时间,哪怕是较长的时间,比如一天。删除操作通常能保持条目的最新状态;而过期机制则是防止错误条目永久存在的保障。

    不进行序列化直接存储值

    Redis存储的是字符串,因此直接传递普通对象会悄无声息地破坏该条目:

    await redis.set(key, JSON.stringify(value)); // correct
    await redis.set(key, value);                 // an object is stored as "[object Object]"
    

    写入时使用 JSON.stringify 进行序列化,读取时使用 JSON.parse 进行反序列化。从一开始就将值包裹在如 { value, freshUntil, staleUntil } 这样的封装结构中,即便最初只使用 value 也可;之后再添加“失效时重新验证”的机制,也就无需同时处理两种格式。

    对于 null 结果也应慎重选择处理方式。通过封装结构存储这些结果,即所谓的负缓存技术,这样重复查询不存在的记录时就无需再访问数据库,通常这是较为理想的做法。而在某些情况下,比如查询落后于主库的副本时,刚插入的记录可能很快就会出现,此时可采用过期时间更短的缓存机制。

    由用户输入生成的无限长度键

    直接从查询字符串派生出的键看似并无危害:

    const key = `search:${req.query.q}`;
    

    每个唯一的搜索字符串都会生成对应的键,任何客户端都可能创建数以百万计的键。后续行为由maxmemory-policy决定。使用allkeys-lru策略时,大量键的涌入会挤掉真正高频使用的键,导致查询命中率急剧下降。而自托管的Redis默认采用noeviction策略,此时内存耗尽后就会因内存不足错误而拒绝写入操作。

    对查询内容进行哈希处理并无帮助:哈希仅能限制每个键的长度,无法减少键的数量。应当对输入数据进行标准化处理(去除空白字符、转为小写、统一参数顺序),使得相同的搜索请求映射到同一个键,同时让这些键的生命周期较短,仅缓存那些高频或处理成本较高的搜索请求。建议有意识地配置maxmemory值及淘汰策略,而非直接使用默认设置。

    在多个环境中共享一个Redis实例

    如果测试环境和生产环境使用同一个实例,那么这两个环境都会读取和写入同一个 user:42。专用实例才是真正的解决方案;作为备选方案,可以为每个键添加命名空间,ioredis 可以帮您实现这一点:

    const redis = new Redis(process.env.REDIS_URL, {
      keyPrefix: `${process.env.APP_ENV}:`,
    });
    

    前缀应基于专门设计的变量,如 APP_ENV,因为测试环境通常也以 NODE_ENV=production 的方式运行。请注意,keyPrefix 仅适用于键,不适用于发布/订阅通道,因此需要您自己将环境信息添加到失效通道名称中。

    修改共享的缓存值

    由于单次执行机制,同时调用的多个请求都会得到同一个共享对象,而非各自的独立副本:

    const a = cacheAside('user:42', load); // both wait on one promise
    const b = cacheAside('user:42', load);
    
    (await a).name = 'changed';
    console.log((await b).name); // 'changed'
    

    从 Redis 获取的任何数据都会为每个调用者解析为一个新的对象,因此问题仅限于共享 Promise 的情况,这也解释了为何该问题很少出现且难以复现。可将缓存值视为不可变,或在修改之前使用 structuredClone() 克隆一份。在开发阶段,通过 Object.freeze 冻结返回值,可以让严格模式下的代码在出现错误赋值时立即抛出异常,而不会悄悄破坏数据。

    关键要点

    • 为每个键设置过期时间:删除操作可保持数据最新,而过期机制则限制了错误状态的持续时间。
    • 采用“先缓存、写入后删除”的策略:先将数据写入数据库,然后在任何事务之外删除(切勿覆盖)该键。对于用户频繁修改的键,需安排定期再次删除。
  • 启用失败即开放模式,并将客户端配置为在断开连接时快速失败而非排队处理命令。
  • 读取时对常用密钥进行保护;通过“失效期间重新验证”与“单次处理”相结合的方式应对大多数流量场景。
  • 仅将发布/订阅机制用于进程内缓存,并设置较短的本地有效时间,因为消息可能会丢失。
  • 将版本化密钥保留用于那些因数据过时会导致错误的结果的数据。
  • 限制可存在的密钥数量;哈希功能仅能限制其长度。
  • 将缓存功能集中到一个封装层中,并根据加载器的实际延迟情况调整其默认参数。
  • 最后需要提醒一点:以上内容均基于单个独立的 Redis 服务器进行假设。Sentinel 和 Cluster 架构采用异步复制方式,故障转移时可能会丢失最新的写入数据,包括封装层所依赖的锁键。在将这类锁用于比简单协调更重要的场景之前,请务必考虑到这一点。