Skip to content

课 1 · 会话缓存与问答缓存

本课目标

用 Redis 存储会话历史和热点问答缓存,理解 TTL 设计和缓存失效策略。

关键理解:Redis 不是因为"项目要显得专业"才引入的,而是因为 SQLite 存会话有具体痛点——每次问答都要全量读写多轮消息,并发场景下锁竞争严重。

为什么用 Redis 存会话

基础课的会话存在 SQLite 里,进阶到 API 服务后暴露两个问题:

问题SQLite 方案Redis 方案
并发读写写锁竞争,高并发慢单线程原子操作,无锁
会话 TTL手写定时清理任务EXPIRE 自动过期
分布式单文件,不能多节点共享独立服务,多节点可共享

注意:进阶课是单节点部署,SQLite 其实也能跑。这里引入 Redis 的真正动机是缓存问答结果——这个场景 SQLite 不擅长。

安装

bash
pnpm add ioredis
pnpm add -D @types/node

Redis 连接封装

typescript
// packages/shared/src/lib/redis.ts
import Redis from 'ioredis'

let redisClient: Redis | null = null

export function getRedis(): Redis {
  if (!redisClient) {
    redisClient = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379', {
      maxRetriesPerRequest: 3,
      lazyConnect: true,
    })

    redisClient.on('error', (err) => {
      console.error('[Redis] 连接错误', err.message)
    })
  }
  return redisClient
}

会话存储

会话 = 一组有序的历史消息,用 Redis List 存储:

typescript
// apps/api/src/services/session.ts
import { getRedis } from '@shared/lib/redis'
import type { Message } from 'ai'

const SESSION_TTL = 60 * 60 * 24 * 7  // 7 天

export const sessionService = {
  async getMessages(sessionId: string): Promise<Message[]> {
    const redis = getRedis()
    const raw = await redis.lrange(`session:${sessionId}`, 0, -1)
    return raw.map((item) => JSON.parse(item) as Message)
  },

  async appendMessage(sessionId: string, message: Message): Promise<void> {
    const redis = getRedis()
    const key = `session:${sessionId}`

    // 追加消息 + 刷新过期时间
    await redis
      .multi()
      .rpush(key, JSON.stringify(message))
      .expire(key, SESSION_TTL)
      .exec()
  },

  async clearSession(sessionId: string): Promise<void> {
    await getRedis().del(`session:${sessionId}`)
  },
}

问答缓存

对于完全相同的问题,不需要再走一遍 RAG + LLM,直接返回缓存答案:

typescript
// apps/api/src/services/cache.ts
import { getRedis } from '@shared/lib/redis'
import { createHash } from 'node:crypto'

const CACHE_TTL = 60 * 60  // 1 小时

function cacheKey(message: string): string {
  // 用内容哈希作为 key,避免 key 太长
  return `qa:${createHash('sha256').update(message.trim().toLowerCase()).digest('hex').slice(0, 16)}`
}

export const qaCache = {
  async get(message: string): Promise<string | null> {
    return getRedis().get(cacheKey(message))
  },

  async set(message: string, answer: string): Promise<void> {
    await getRedis().setex(cacheKey(message), CACHE_TTL, answer)
  },

  async invalidate(pattern?: string): Promise<void> {
    if (!pattern) {
      // 清空所有问答缓存(知识库更新后调用)
      const keys = await getRedis().keys('qa:*')
      if (keys.length > 0) await getRedis().del(...keys)
    }
  },
}

在 Agent service 里接入缓存:

typescript
// apps/api/src/services/agent.ts
export const agentService = {
  async ask({ message, sessionId }: AskParams) {
    // 先查缓存
    const cached = await qaCache.get(message)
    if (cached) {
      return { answer: cached, sources: [], sessionId, cached: true }
    }

    // 缓存未命中,走完整 RAG + LLM 流程
    const result = await runAgent({ message, sessionId })

    // 写入缓存(只缓存成功回答)
    await qaCache.set(message, result.answer)

    return result
  },
}

TTL 设计考量

数据类型推荐 TTL原因
会话消息7 天用户可能几天后继续对话
问答缓存1 小时知识库可能更新,答案会变
限流计数1 分钟配合滑动窗口限流
任务锁10 分钟防止任务长时间占用

缓存失效:知识库更新后

文档导入完成后,旧的问答缓存可能已经过时:

typescript
// apps/worker/src/processor.ts(完成处理后)
await db.updateJob(job.id, { status: 'completed' })

// 知识库内容变了,清掉问答缓存
await qaCache.invalidate()

本节产物

packages/shared/src/lib/
  redis.ts              # Redis 连接封装
apps/api/src/services/
  session.ts            # 会话存储(Redis List + TTL)
  cache.ts              # 问答缓存(SHA256 key + TTL)

面试追问

问答缓存如何避免返回过期答案?

两个机制结合:TTL 自动过期(1 小时),以及知识库更新时主动清空缓存。TTL 保证兜底,主动失效保证即时性。如果担心并发下的 Cache Stampede(多请求同时 miss,同时跑 LLM),可以加分布式锁或 "stale-while-revalidate" 模式。

Redis 的 LRange 取全量会不会有性能问题?

LRANGE key 0 -1 对长 List 确实有性能问题。实践中会限制会话消息条数(比如最多保留最近 20 条),并在 append 时检查长度超限就 ltrim 截断。

面向前端工程师和独立开发者的 AI 应用工程课程