使用 Claude Code 的缓存策略:Redis 模式与缓存失效

发布: (2026年3月11日 GMT+8 12:07)
10 分钟阅读
原文: Dev.to

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 错误——这些方法在适当情况下返回 nullvoidfalse
  • 所有错误均通过 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.setcatch 会记录错误;函数仍然返回数据库结果,实现优雅降级。

缓存失效逻辑(用户资料更新)

  • 失效 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;
}

关键要点

  1. Domain events 将缓存关注点从业务逻辑中分离。
  2. 使用 wild‑card scan (SCAN) 高效查找并删除相关的列表键。
  3. 始终记录缓存操作中的错误,以避免静默失败。

基于游标的分页用户列表缓存

  • 缓存键: 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.createduser.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.getcache.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 上
0 浏览
Back to Blog

相关文章

阅读更多 »