使用 Claude Code 的缓存策略:Redis 模式与缓存失效
Source: Dev.to – Caching Strategies with Claude Code, Redis Patterns, and Cache Invalidation
(请提供您希望翻译的正文内容,我将按照要求保留源链接、格式、代码块和技术术语,仅翻译文本部分。)
缓存规则
缓存内容
| 数据类型 | TTL |
|---|---|
| 主数据(用户角色、配置、功能标记) | 5 分钟 |
| 高耗计算(聚合、报告) | 15 分钟 |
| 用户会话 | 会话过期 |
| 来自外部服务的 API 响应 | 1 分钟 |
不缓存的内容
- 与用户相关的事务数据(订单、进行中的付款)
- 实时数据(股票价格、实时通知)
- 任何在写入后必须立即保持一致的内容
缓存键
格式:
{service}:{entity}:{id}:{version} {service}:{entity}:list:{filter_hash}示例:
user:profile:123 product:list:abc123 config:features:v1重要: 绝不要在缓存键中直接使用原始用户输入(存在注入风险)。
失效策略
- 写穿 – 每次写入时更新缓存(确保一致性)。
- 基于 TTL – 当可接受最终一致性时使用短 TTL。
- 基于事件 – 在领域事件触发时失效(对关键数据更佳)。
Redis 配置
- 客户端:
src/lib/redis.ts(单例) - 默认序列化:JSON
- 错误处理:将 Redis 错误视为缓存未命中(永不导致请求失败)
缓存工具模块 (src/lib/cache.ts)
// src/lib/cache.ts
import { createClient } from 'redis';
import logger from './logger';
// Create a singleton Redis client – configure connection options as needed.
const client = createClient();
client.on('error', (err) => logger.error('Redis error', err));
export const cache = {
/**
* Retrieve a value from the cache.
* @param key Cache key
* @returns Parsed value or `null` if missing / on error
*/
async get<T>(key: string): Promise<T | null> {
try {
const raw = await client.get(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch (err) {
logger.error('Cache get error', err);
return null; // treat errors as a cache miss
}
},
/**
* Store a value in the cache.
* @param key Cache key
* @param value Value to cache (will be JSON‑stringified)
* @param ttlSeconds Time‑to‑live in seconds
*/
async set(key: string, value: unknown, ttlSeconds: number): Promise<void> {
try {
const serialized = JSON.stringify(value);
await client.set(key, serialized, { EX: ttlSeconds });
} catch (err) {
logger.error('Cache set error', err);
// Swallow the error – the request should continue
}
},
/**
* Delete one or more keys from the cache.
* @param key Cache key or array of keys
*/
async del(key: string | string[]): Promise<void> {
try {
if (Array.isArray(key)) {
await client.del(...key);
} else {
await client.del(key);
}
} catch (err) {
logger.error('Cache delete error', err);
}
},
/**
* Check whether a key exists in the cache.
* @param key Cache key
* @returns `true` if the key exists, otherwise `false`
*/
async exists(key: string): Promise<boolean> {
try {
const result = await client.exists(key);
return result === 1;
} catch (err) {
logger.error('Cache exists error', err);
return false; // treat errors as “does not exist”
}
},
};- 永不抛出 Redis 错误——这些方法在适当情况下返回
null、void或false。 - 所有错误均通过
logger.ts记录,但 不会 导致应用崩溃。
getUserProfile() 的缓存旁路模式
// Example implementation
import { cache, db, logger } from "./services";
interface UserProfile {
id: string;
name: string;
email: string;
// …other fields
}
/**
* Retrieves a user profile using the cache‑aside pattern.
*
* @param userId - The identifier of the user.
* @returns The user profile or `null` if the user does not exist.
*/
export async function getUserProfile(
userId: string,
): Promise<UserProfile | null> {
const cacheKey = `user:profile:${userId}`;
// 1️⃣ Try cache first
const cached = await cache.get<UserProfile>(cacheKey);
if (cached) {
return cached;
}
// 2️⃣ Fetch from DB
const profile = await db.user.findUnique({
where: { id: userId },
});
if (!profile) {
return null;
}
// 3️⃣ Store in cache (non‑blocking)
cache
.set(cacheKey, profile, 300) // TTL = 300 seconds (5 min)
.catch(logger.error);
return profile;
}行为
- 缓存命中 – 立即返回缓存的数据。
- 缓存未命中 – 从数据库获取用户资料,缓存 5 分钟,随后返回。
- Redis 错误 –
cache.set的catch会记录错误;函数仍然返回数据库结果,实现优雅降级。
缓存失效逻辑(用户资料更新)
- 失效
user:profile:{userId}以及包含该用户的任何列表缓存。 - 使用 领域事件 (
user.updated) 而不是直接从服务调用缓存。
事件处理器
// src/events/userUpdated.ts
import { cache } from '../lib/cache';
import logger from '../lib/logger';
import { Redis } from 'ioredis'; // if you need the raw client for scanning
// If you need the raw Redis client, expose it from your cache lib
// const client: Redis = cache.getClient();
export async function handleUserUpdated(event: { userId: string }) {
const { userId } = event;
const profileKey = `user:profile:${userId}`;
// 1️⃣ Invalidate the profile cache
await cache.del(profileKey).catch(logger.error);
// 2️⃣ Invalidate list caches (wild‑card)
const pattern = 'user:list:*';
try {
// Using the low‑level client to iterate over matching keys
const stream = cache.client.scanIterator({ MATCH: pattern });
const keys: string[] = [];
for await (const key of stream) {
keys.push(key);
}
if (keys.length) {
await cache.del(keys);
}
} catch (err) {
logger.error('Error invalidating user list caches', err);
}
}发出事件的服务
// src/services/userService.ts
import { emit } from '../events/bus';
import { PrismaClient, User } from '@prisma/client';
const db = new PrismaClient();
export async function updateUserProfile(
userId: string,
data: Partial<User>
) {
const updated = await db.user.update({
where: { id: userId },
data,
});
// Emit a domain event so cache logic stays decoupled
await emit('user.updated', { userId });
return updated;
}关键要点
- Domain events 将缓存关注点从业务逻辑中分离。
- 使用 wild‑card scan (
SCAN) 高效查找并删除相关的列表键。 - 始终记录缓存操作中的错误,以避免静默失败。
基于游标的分页用户列表缓存
- 缓存键:
user:list:{cursor}:{limit} - TTL: 2 分钟(因为列表在变更后可能变陈旧,所以时间更短)
- 失效: 在任何用户创建 / 删除时,使用
SCAN批量删除所有user:list:*键。
// src/lib/userListCache.ts
import { cache } from './cache';
import logger from './logger';
import { client as redisClient } from './redis'; // expose raw client for SCAN
import { db } from './db'; // Prisma (or your ORM) instance
/**
* Retrieves a paginated list of users, using a cursor‑based approach.
* Results are cached for 2 minutes.
*
* @param cursor - The ID of the last item from the previous page (empty for the first page).
* @param limit - Number of items to fetch.
* @returns - An array of user records.
*/
export async function getUserList(
cursor: string,
limit: number
): Promise<any[]> { // Replace `any` with your User type
const cacheKey = `user:list:${cursor}:${limit}`;
const cached = await cache.get(cacheKey);
if (cached) return cached;
const users = await db.user.findMany({
take: limit,
cursor: cursor ? { id: cursor } : undefined,
orderBy: { id: 'asc' },
});
// Store result (non‑blocking)
cache.set(cacheKey, users, 120).catch(logger.error);
return users;
}
/**
* Removes every cached user‑list entry.
* Intended to be called after mutations that affect the list (e.g., create/delete).
*/
export async function invalidateUserListCaches(): Promise<void> {
const pattern = 'user:list:*';
try {
const stream = redisClient.scanIterator({ MATCH: pattern });
const keys: string[] = [];
for await (const key of stream) {
keys.push(key);
}
if (keys.length) {
await cache.del(keys);
}
} catch (err) {
logger.error('Failed to invalidate user list caches', err);
}
}用法 – 在你的领域事件处理器中调用
invalidateUserListCaches()(例如user.created、user.deleted),以便后续的分页请求获取最新数据。
缓存检查 Hook (.claude/hooks/check_cache.py)
该 Hook 会扫描路由处理文件,查找未被缓存逻辑包装的直接 Prisma findMany 调用。如果发现此类调用,它会向 stderr 输出警告。
# .claude/hooks/check_cache.py
import json
import re
import sys
# Read the JSON payload from stdin
data = json.load(sys.stdin)
# Extract the relevant fields
content = data.get("tool_input", {}).get("content", "") or ""
fp = data.get("tool_input", {}).get("file_path", "")
# ----------------------------------------------------------------------
# Only run the check for route‑handler files (ignore repositories, etc.)
# ----------------------------------------------------------------------
if not fp or ("routes" not in fp and "controllers" not in fp):
sys.exit(0)
# --------------------------------------------------------------
# Flag Prisma findMany calls that lack surrounding cache logic
# --------------------------------------------------------------
if re.search(r"prisma\.(user|product|config)\.findMany", content):
if "cache.get" not in content and "cache.set" not in content:
print(
"[CACHE] DB call without caching in route handler",
file=sys.stderr,
)
sys.exit(0)该脚本对非路由文件会静默退出,仅在检测到 Prisma findMany 调用且未使用任何 cache.get 或 cache.set 时才报告警告。
getUserProfile 的测试
// tests/getUserProfile.test.ts
import { cache } from '../src/lib/cache';
import { db } from '../src/lib/prisma';
import { getUserProfile } from '../src/services/userService';
import logger from '../src/lib/logger';
import type { UserProfile } from '../src/types';
jest.mock('../src/lib/cache');
jest.mock('../src/lib/prisma');
jest.mock('../src/lib/logger');
describe('getUserProfile', () => {
const userId = '123';
const profile = { id: userId, name: 'Alice' } as UserProfile;
afterEach(() => {
jest.clearAllMocks();
});
it('returns cached data on cache hit', async () => {
(cache.get as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
expect(db.user.findUnique).not.toHaveBeenCalled();
});
it('fetches from DB and caches on cache miss', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(db.user.findUnique as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
expect(db.user.findUnique).toHaveBeenCalledWith({
where: { id: userId },
});
expect(cache.set).toHaveBeenCalledWith(
`user:profile:${userId}`,
profile,
300,
);
});
it('returns null when DB does not have the user', async () => {
(cache.get as jest.Mock).mockResolvedValue(null);
(db.user.findUnique as jest.Mock).mockResolvedValue(null);
const result = await getUserProfile(userId);
expect(result).toBeNull();
expect(cache.set).not.toHaveBeenCalled();
});
it('gracefully degrades on Redis error', async () => {
(cache.get as jest.Mock).mockRejectedValue(new Error('Redis down'));
(db.user.findUnique as jest.Mock).mockResolvedValue(profile);
const result = await getUserProfile(userId);
expect(result).toEqual(profile);
// Cache set should still be attempted (non‑blocking)
expect(cache.set).toHaveBeenCalled();
});
});附加参考
- Code Review Pack(¥980)在 PromptWorks 上