Appearance
课 1 · 会话缓存与问答缓存
本课目标
用 Redis 存储会话历史和热点问答缓存,理解 TTL 设计和缓存失效策略。
关键理解:Redis 不是因为"项目要显得专业"才引入的,而是因为 SQLite 存会话有具体痛点——每次问答都要全量读写多轮消息,并发场景下锁竞争严重。
为什么用 Redis 存会话
基础课的会话存在 SQLite 里,进阶到 API 服务后暴露两个问题:
| 问题 | SQLite 方案 | Redis 方案 |
|---|---|---|
| 并发读写 | 写锁竞争,高并发慢 | 单线程原子操作,无锁 |
| 会话 TTL | 手写定时清理任务 | EXPIRE 自动过期 |
| 分布式 | 单文件,不能多节点共享 | 独立服务,多节点可共享 |
注意:进阶课是单节点部署,SQLite 其实也能跑。这里引入 Redis 的真正动机是缓存问答结果——这个场景 SQLite 不擅长。
安装
bash
pnpm add ioredis
pnpm add -D @types/nodeRedis 连接封装
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 截断。