Advanced (更新: 2026/6/1)

Claude Code Redis 缓存实战:TTL、失效策略与防雪崩设计

用 Claude Code 设计 Redis 缓存的实战指南,覆盖 key 设计、TTL、失效、Node.js 代码、测试与审查清单。

Claude Code Redis 缓存实战:TTL、失效策略与防雪崩设计

Redis 是一种把数据放在内存中的高速键值存储,适合缓存数据库聚合、外部 API 结果、排行榜、短期会话和限流计数。它的危险也很直接:如果只让 Claude Code “加一个 Redis 缓存”,而没有说明什么数据能变旧、多久必须刷新、更新后删哪些 key,最终可能得到更快但不可信的页面。

这篇文章给出一个可以直接交给 Claude Code 的 Redis 缓存工作流:缓存策略、key 设计、TTL、失效、stampede 防护、Node.js 实现、测试和 review 清单。更完整的多层缓存背景可以参考 Claude Code 真实应用缓存策略,如果 Redis 同时用于队列,也建议读 Claude Code 队列与后台任务

flowchart LR
  Request["HTTP request"] --> Cache["Redis cache"]
  Cache -->|hit| Response["Fast response"]
  Cache -->|miss| Lock["Short lock"]
  Lock --> Loader["DB or external API"]
  Loader --> Cache
  Loader --> Response
  Admin["Update event"] --> Invalidate["Delete known keys"]
  Invalidate --> Cache

先写缓存策略

在实现之前,把下面这种策略放进 CLAUDE.md 或 Claude Code 任务正文。代码能告诉模型数据从哪里来,但不能自动判断业务上允许旧多久。

数据key 示例TTL 建议失效时机风险
公开商品列表claudecodelab:v1:products:list:zh5 分钟商品更新成功后删除列表 key价格变化不能只等 TTL
文章详情claudecodelab:v1:posts:item:{slug}10 分钟发布、编辑、下线后删除不缓存草稿和预览
后台 PV 汇总claudecodelab:v1:analytics:daily:{date}30 秒通常只靠 TTL不用于财务真值
外部 API 结果claudecodelab:v1:exchange-rate:usd-jpy1 到 15 分钟手动刷新或 TTL检查服务条款
登录用户信息默认不缓存0 秒避免个人数据进入共享缓存

Claude Code 的任务要写清楚:公开且可再生成的数据可以进 Redis;权限、账单、认证状态和个人资料默认不要进 Redis。

Claude Code 任务模板

请为这个 Node.js 应用增加 Redis cache-aside 层。

要求:
1. 使用官方 node-redis 包,也就是 redis
2. key 格式为 claudecodelab:v1:{domain}:{resource}:{id}
3. TTL 从缓存策略表选择,并加入 10% 以内的 jitter
4. 更新流程必须先完成 DB write,成功后再删除已知相关 key
5. 生产代码禁止使用 KEYS;需要批量处理时用已知 key、SCAN 或相关 key Set
6. 对热门 key 的同时 miss 增加短锁,防止 cache stampede
7. 用 node:test 覆盖 key 生成、TTL 范围、getOrSet、并发 miss

返回:
- 修改文件
- 执行过的测试
- 明确排除在缓存外的数据和原因

这段模板也可以放进 Claude Code code review checklist,让 review 不只看“是否变快”,还看“是否能正确失效”。

可运行的 Node.js 实现

Redis 官方 Node.js 客户端是 redis 包,连接方式见 node-redis guide

mkdir redis-cache-demo
cd redis-cache-demo
npm init -y
npm install redis
docker run --name redis-cache-demo -p 6379:6379 -d redis:7-alpine

先集中管理 key 和 TTL。

// cache-policy.js
const CACHE_PREFIX = "claudecodelab";
const CACHE_VERSION = "v1";

const CACHE_POLICY = {
  productList: { ttl: 300, jitter: 30 },
  productItem: { ttl: 600, jitter: 60 },
  dailyStats: { ttl: 30, jitter: 5 },
};

function normalizePart(value) {
  const part = String(value).trim().toLowerCase();
  if (part.length === 0) {
    throw new Error("cache key part must not be empty");
  }
  return encodeURIComponent(part);
}

function cacheKey(parts) {
  if (!Array.isArray(parts) || parts.length === 0) {
    throw new Error("cacheKey requires a non-empty parts array");
  }
  return [CACHE_PREFIX, CACHE_VERSION, ...parts.map(normalizePart)].join(":");
}

function ttlWithJitter(baseSeconds, maxJitterSeconds = 30) {
  if (!Number.isInteger(baseSeconds) || baseSeconds <= 0) {
    throw new Error("base TTL must be a positive integer");
  }
  const jitter = Math.max(0, Math.floor(maxJitterSeconds));
  return baseSeconds + Math.floor(Math.random() * (jitter + 1));
}

module.exports = { CACHE_POLICY, cacheKey, ttlWithJitter };

连接使用单例,避免每个请求都重新 connect()

// redis-client.js
const { createClient } = require("redis");

const redis = createClient({
  url: process.env.REDIS_URL || "redis://localhost:6379",
});

redis.on("error", (error) => {
  console.error("Redis Client Error", error);
});

let connecting;

async function getRedis() {
  if (redis.isOpen) return redis;
  if (!connecting) {
    connecting = redis.connect();
  }
  await connecting;
  return redis;
}

async function closeRedis() {
  if (redis.isOpen) {
    await redis.quit();
  }
  connecting = undefined;
}

module.exports = { getRedis, closeRedis };

核心 helper 使用 cache-aside:先读 Redis,miss 时才调用 loader,并用短锁减少热门 key 同时过期造成的 DB 冲击。

// redis-cache.js
const { randomUUID } = require("node:crypto");

const UNLOCK_SCRIPT = `
if redis.call("get", KEYS[1]) == ARGV[1] then
  return redis.call("del", KEYS[1])
end
return 0
`;

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

class RedisJsonCache {
  constructor(redis, options = {}) {
    this.redis = redis;
    this.defaultTtl = options.defaultTtl || 300;
    this.lockMs = options.lockMs || 5000;
    this.waitMs = options.waitMs || 50;
    this.waitRetries = options.waitRetries || 10;
  }

  async get(key) {
    const raw = await this.redis.get(key);
    if (raw === null) return null;
    try {
      return JSON.parse(raw);
    } catch {
      await this.redis.del(key);
      return null;
    }
  }

  async set(key, value, ttlSeconds = this.defaultTtl) {
    if (!Number.isInteger(ttlSeconds) || ttlSeconds <= 0) {
      throw new Error("ttlSeconds must be a positive integer");
    }
    await this.redis.set(key, JSON.stringify(value), { EX: ttlSeconds });
  }

  async invalidate(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    if (list.length === 0) return 0;
    return this.redis.del(list);
  }

  async getOrSet(key, ttlSeconds, loader) {
    const cached = await this.get(key);
    if (cached !== null) {
      return { value: cached, cacheStatus: "hit" };
    }

    const lockKey = `${key}:lock`;
    const token = randomUUID();
    const acquired = await this.redis.set(lockKey, token, { NX: true, PX: this.lockMs });

    if (acquired === "OK") {
      try {
        const fresh = await loader();
        await this.set(key, fresh, ttlSeconds);
        return { value: fresh, cacheStatus: "miss" };
      } finally {
        await this.redis.eval(UNLOCK_SCRIPT, { keys: [lockKey], arguments: [token] });
      }
    }

    for (let attempt = 0; attempt < this.waitRetries; attempt += 1) {
      await sleep(this.waitMs);
      const afterWait = await this.get(key);
      if (afterWait !== null) {
        return { value: afterWait, cacheStatus: "hit-after-wait" };
      }
    }

    const fallback = await loader();
    await this.set(key, fallback, Math.max(5, Math.floor(ttlSeconds / 3)));
    return { value: fallback, cacheStatus: "miss-after-timeout" };
  }
}

module.exports = { RedisJsonCache };

应用侧可以这样调用。真实项目里把 loadProductsFromDb() 换成 Prisma、Supabase 或 API 客户端即可;相关主题可看 Prisma ORM with Claude CodeSupabase integration with Claude Code

// demo-products.js
const { CACHE_POLICY, cacheKey, ttlWithJitter } = require("./cache-policy");
const { getRedis, closeRedis } = require("./redis-client");
const { RedisJsonCache } = require("./redis-cache");

const db = {
  products: [
    { id: "p1", locale: "zh", name: "CLAUDE.md 模板", price: 9, published: true },
    { id: "p2", locale: "zh", name: "Claude Code 培训", price: 199, published: true },
  ],
};

async function loadProductsFromDb(locale) {
  await new Promise((resolve) => setTimeout(resolve, 80));
  return db.products.filter((product) => product.locale === locale && product.published);
}

async function listPublishedProducts(cache, locale) {
  const key = cacheKey(["products", "list", locale]);
  const ttl = ttlWithJitter(CACHE_POLICY.productList.ttl, CACHE_POLICY.productList.jitter);
  return cache.getOrSet(key, ttl, () => loadProductsFromDb(locale));
}

async function main() {
  const redis = await getRedis();
  const cache = new RedisJsonCache(redis);
  const first = await listPublishedProducts(cache, "zh");
  const second = await listPublishedProducts(cache, "zh");
  console.log({ firstStatus: first.cacheStatus, secondStatus: second.cacheStatus, products: second.value });
  await cache.invalidate([cacheKey(["products", "list", "zh"])]);
  await closeRedis();
}

main().catch(async (error) => {
  console.error(error);
  await closeRedis();
  process.exitCode = 1;
});
node demo-products.js

测试重点

缓存 bug 很容易被“响应很快”掩盖,所以要测试 key、TTL、命中逻辑和并发 miss。

// redis-cache.test.js
const test = require("node:test");
const assert = require("node:assert/strict");
const { cacheKey, ttlWithJitter } = require("./cache-policy");
const { RedisJsonCache } = require("./redis-cache");

class FakeRedis {
  constructor() {
    this.store = new Map();
  }

  async get(key) {
    const entry = this.store.get(key);
    if (!entry) return null;
    if (entry.expiresAt && entry.expiresAt <= Date.now()) {
      this.store.delete(key);
      return null;
    }
    return entry.value;
  }

  async set(key, value, options = {}) {
    if (options.NX && (await this.get(key)) !== null) return null;
    const ttlMs = options.PX || (options.EX ? options.EX * 1000 : 0);
    this.store.set(key, { value, expiresAt: ttlMs ? Date.now() + ttlMs : 0 });
    return "OK";
  }

  async del(keys) {
    const list = Array.isArray(keys) ? keys : [keys];
    let deleted = 0;
    for (const key of list) {
      if (this.store.delete(key)) deleted += 1;
    }
    return deleted;
  }

  async eval(_script, options) {
    const [key] = options.keys;
    const [token] = options.arguments;
    if ((await this.get(key)) === token) return this.del(key);
    return 0;
  }
}

test("cacheKey encodes dynamic parts", () => {
  assert.equal(cacheKey(["Products", "List", "zh/CN"]), "claudecodelab:v1:products:list:zh%2Fcn");
});

test("ttlWithJitter stays inside the expected range", () => {
  for (let i = 0; i < 50; i += 1) {
    const ttl = ttlWithJitter(300, 30);
    assert.ok(ttl >= 300);
    assert.ok(ttl <= 330);
  }
});

test("getOrSet caches the first loader result", async () => {
  const cache = new RedisJsonCache(new FakeRedis());
  let loads = 0;
  const first = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p1" }];
  });
  const second = await cache.getOrSet("products:list", 60, async () => {
    loads += 1;
    return [{ id: "p2" }];
  });
  assert.equal(first.cacheStatus, "miss");
  assert.equal(second.cacheStatus, "hit");
  assert.equal(loads, 1);
  assert.deepEqual(second.value, [{ id: "p1" }]);
});

test("getOrSet waits instead of running duplicate loaders", async () => {
  const cache = new RedisJsonCache(new FakeRedis(), { waitMs: 5, waitRetries: 20 });
  let loads = 0;
  const loader = async () => {
    loads += 1;
    await new Promise((resolve) => setTimeout(resolve, 20));
    return { total: 42 };
  };
  const results = await Promise.all([
    cache.getOrSet("analytics:daily", 30, loader),
    cache.getOrSet("analytics:daily", 30, loader),
  ]);
  assert.equal(loads, 1);
  assert.deepEqual(results[0].value, { total: 42 });
  assert.deepEqual(results[1].value, { total: 42 });
});
node --test redis-cache.test.js

实际使用场景

第一个场景是公开目录和文章列表。所有读者看到同一结果,更新后删除列表 key 和详情 key 即可。

第二个场景是后台统计。PV、转化率、收入预览可以短暂缓存,但权限判断、发票、支付状态不能用这个缓存代替真实 DB。

第三个场景是外部 API。汇率、天气、SaaS 套餐、公开 GitHub 元数据可以短时间缓存,降低限流压力。

第四个场景是限流。Redis 的 INCREXPIRE 很适合短窗口计数,但认证数据需要额外的安全设计。

常见坑

最常见的问题是 key 不完整。products:list 没有区分语言、货币、租户、筛选条件和发布状态。凡是会改变结果的条件,都要进入 key。

第二个问题是先删缓存、后写 DB。正确顺序是 DB 写入成功、删除已知 Redis key、必要时 purge CDN。

第三个问题是在生产代码使用 KEYS user:*。keyspace 大时它会阻塞 Redis。优先使用已知 key、相关 key Set 或基于 SCAN 的维护命令。

还要注意 null 值。上面的实现把 null 当成 miss;如果要缓存“未找到”,请保存 { found: false } 这样的对象。

Review 清单

  • 个人信息、权限、账单数据是否被排除或有明确理由
  • key 是否包含 locale、tenant、role、query、version 等条件
  • TTL 是否能用业务鲜度解释
  • 更新是否在 DB 成功后删除相关 key
  • 生产代码是否避免 KEYS
  • 是否有 lock、jitter 或 stale fallback 防止 stampede
  • 测试是否覆盖 key、TTL、命中和并发 miss
  • 日志或指标是否能看到 hit rate 和 fallback

官方资料与下一步

建议在 Claude Code 任务中直接附上 Redis 官方文档node-redis guideRedis patternsRedis optimization

如果想把缓存策略、CLAUDE.md、prompt 和 review 清单做成团队流程,可以从 ClaudeCodeLab 产品与模板开始。需要把 Redis、CDN、DB 更新和监控映射到真实项目时,可以使用 Claude Code 培训与咨询

Masa 在小型 Node.js demo 中实际测试后,第一次列表请求调用 loader,第二次变成 Redis hit,并发 miss 测试也把 loader 控制在一次。真正的收益不只是 Redis 变快,而是让 Claude Code 在明确的 key、TTL、失效和 review 规则下工作。

#Claude Code #Redis #缓存 #Node.js #性能优化
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。