Claude Code Redis 缓存实战:TTL、失效策略与防雪崩设计
用 Claude Code 设计 Redis 缓存的实战指南,覆盖 key 设计、TTL、失效、Node.js 代码、测试与审查清单。
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:zh | 5 分钟 | 商品更新成功后删除列表 key | 价格变化不能只等 TTL |
| 文章详情 | claudecodelab:v1:posts:item:{slug} | 10 分钟 | 发布、编辑、下线后删除 | 不缓存草稿和预览 |
| 后台 PV 汇总 | claudecodelab:v1:analytics:daily:{date} | 30 秒 | 通常只靠 TTL | 不用于财务真值 |
| 外部 API 结果 | claudecodelab:v1:exchange-rate:usd-jpy | 1 到 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 Code 和 Supabase 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 的 INCR 加 EXPIRE 很适合短窗口计数,但认证数据需要额外的安全设计。
常见坑
最常见的问题是 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 guide、Redis patterns 和 Redis 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 规则下工作。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
让 Claude Code 真正进入可验证的工作流
先用免费 PDF 固定基础,再用 Gumroad 教材复用工作流;如果涉及团队导入、权限或收入路径,可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code 团队使用成本看不清时,先建预算日志
记录谁在什么工作中使用 Claude Code,以及产生了什么结果,适合团队导入前一周试跑。
提交前的三分钟检查:确认 Claude Code 改动了哪些范围再 commit
教你在 commit 前用三分钟揪出 Claude Code 顺手扩大的改动:按顺序确认 diff 范围、验证日志,再挑选要 stage 的文件。
Claude Code 团队上线前先建一张「风险台账」:权限、CI、发布全都别踩坑
把 Claude Code 从个人实验推到团队上线时,怎么用一张风险台账防住权限、CI、发布三类事故。附可复制的提示词和能直接跑的代码。