事情是这样的。上周 review 代码的时候,我盯着一段面试答题的链路看了很久。用户答完第 5 题,系统算完分、推完流程,一切看起来都很顺。但就在这一瞬间,如果 Redis 里的某个 key 恰好过期了,或者实例抖了一下,下一条请求进来就会一脸懵,当前到底答到哪了?刚才那轮算分了吗?要不要重新算?
这就是长会话系统最真实的痛点。不是数据全没了,而是系统突然「失忆」了。它还记得这个 session 存在,却想不起自己该干什么。
为什么会丢状态
很多兄弟第一反应是,丢状态不就是 Redis 挂了或者数据被删了吗。但在 AI 模拟面试这种场景里,丢状态往往不是物理层面的数据消失,而是系统失去了继续推进当前会话所需的上下文。
比如当前进行到第几题、这一题是不是追问、最近几次问答内容是什么、当前流程节点处于出题中还是作答中、当前总分是多少。这些信息只要缺了一部分,系统就没法安全往下走。
短请求系统通常很简单,请求进来、算一次、返回结果、结束。缓存失效了大不了重新查一次数据库。但 AI 面试不是这种模式。一场模拟面试可能持续十几分钟甚至更久,用户会不断推进同一条链路,系统既要记住历史,又要知道当前进度。
一旦会话被拉长,状态就不再是一次请求的临时变量,而变成一份需要跨请求保存、跨时间延续、跨节点共享的运行上下文。这时候状态本身就会变得脆弱。你要防的不是一个请求失败,而是半小时内的缓存过期、某次请求写成功但补充刷新失败、某台实例抖动导致局部状态没写完、并发请求把彼此状态覆盖掉。
所以长会话不是把短请求拉长一点而已,而是把状态管理难度提升了一个量级。
四层存储模型
面对这个问题,最直觉的想法是,既然 Redis 会丢,那就多存一份到数据库。但真这么做又会遇到新问题,如果所有数据都往 Mongo 里塞,高频更新会拖垮性能;如果只存 Redis,恢复时又找不到依据。
我们的解法是分层。不是简单的「缓存 + 数据库」,而是把长会话运行态拆成四层,每层回答一个不同的问题。
| 层次 | 回答的问题 | 典型特点 | 允许缺失吗 |
|---|---|---|---|
| Redis 在线运行态 | 当前请求最快该看什么 | 高频、低延迟、可淘汰 | 允许,从 Mongo 恢复 |
| Mongo Hot Snapshot | Redis 丢了以后,当前会话大概恢复到哪里 | 贴近当前态、字段少、强调推进边界 | 不希望丢 |
| Mongo Cold Snapshot | 恢复时,题目、简历、建议这些背景材料从哪来 | 内容大、变化慢、恢复时必须够用 | 不希望丢 |
| Mongo Turn Archive | 过去到底发生过什么,这次请求之前是不是已经成功过 | 追加型、顺序型、可审计 | 不希望丢 |
Redis 在线运行态
Redis 承载的是当前请求会高频触达的一组工作键。从代码里看,这些 key 主要集中在 InterviewCacheKeys 里。
流程与题目相关的在线态,比如 interview:questions:session:{sessionId}、interview:flow:session:{sessionId}、interview:follow_up_questions:session:{sessionId}。当前请求通常要立刻知道当前题目集合是什么、当前流程走到哪、当前题是否已经进入追问分支。
得分相关的在线态,比如 interview:score:session:{sessionId}、interview:score_sum:session:{sessionId}、interview:score_count:session:{sessionId}。这些分数数据以轻量标量形式存在,不需要每次都回源组装。
回放与上下文相关的在线态,interview:turns:session:{sessionId} 是一个 Redis List,保存最近几轮 turn。它的作用是让当前会话快速拿到在线 turn 列表与最近上下文,做上下文拼接、展示回放、当前题状态确认。正常追加路径下会做长度裁剪,最多保留 200 条。完整历史不在这里。
幂等相关的在线态,interview:answer:req:session:{sessionId}、interview:turn:req:session:{sessionId}。用来快速判断某个 requestId 当前缓存里是否已经见过,当前请求能不能直接短路回放。
材料相关的在线副本,比如 interview:suggestions:session:{sessionId}、interview:resume_context:session:{sessionId}、interview:direction:session:{sessionId}。这些数据看起来更像「材料」而不是「推进状态」,但在 Redis 里是在线副本,在 Mongo 冷快照里才是恢复底座。
为什么 Redis 数据这么分散?因为不同数据的访问模式完全不同。questions、suggestions 适合 Hash,turns 适合 List,answerRequest 适合 Set,score 适合 String,resumeContext 适合 JSON 字符串。如果全部揉成一个大 JSON,每次小改动都得整包读写,热字段和冷字段相互拖累,并发覆盖更严重,局部恢复也做不到。
Mongo Hot Snapshot
Hot Snapshot 对应实体 InterviewSessionRuntimeHotSnapshot,它的核心作用不是存历史,也不是存大材料,而是存「当前会话还能继续跑下去所需的关键信息摘要」。
java
@Document(collection = "interview_session_runtime_hot_snapshot")
public class InterviewSessionRuntimeHotSnapshot {
@Indexed(unique = true)
private String sessionId;
private Long snapshotVersion;
private InterviewRuntimeConfidence rebuildConfidence;
private InterviewFlowState flow;
private InterviewRuntimeScoreAggregate scoreAggregate;
private List<InterviewTurnLog> recentTurns;
private Long archiveWatermark;
private String lastAppliedRequestId;
// ...
}
你看到的这些字段,基本上都是为了做 Redis 挂了之后的最快恢复。它不追求最全,只追求两件事,足够接近当前运行态,足够轻能高频刷新。
Mongo Cold Snapshot
Cold Snapshot 对应实体 InterviewSessionRuntimeColdSnapshot,可以理解成放在 Mongo 里的「恢复材料包」。它不负责告诉你「现在做到第几题」,而是负责告诉你「要继续这场面试,还需要哪些稳定背景资料」。
假设 Redis 丢了,系统知道你当前在第 5-F1 题,这个「当前位置」可以靠 Hot Snapshot 恢复。但只知道「在第 5 题追问」还不够,系统还得知道第 5 题题面是什么、有没有建议提示、候选人的简历上下文是什么、当前面试方向是什么。这些东西大多不是每轮答题都会变,但恢复时又必须有。于是它们就不适合放在高频刷新的 Hot Snapshot,而适合集中放进 Cold Snapshot。
Mongo Turn Archive
Turn Archive 对应实体 InterviewSessionTurnArchive,是「每一轮已提交答题的正式流水账」。它不负责告诉你「现在大概在哪」,而是负责告诉你「之前每一轮具体发生了什么,而且这些记录是可持久、可排序、可按 requestId 查到的」。
java
@Document(collection = "interview_session_turn_archive")
public class InterviewSessionTurnArchive {
@Indexed
private String sessionId;
@Indexed
private String requestId;
@Indexed
private Long seq;
private Long snapshotVersion;
private InterviewTurnLog turnPayload;
private Date createdAt;
}
这里有个关键概念叫 turn。在这套面试系统里,InterviewTurnLog 不是整场会话,也不是单条聊天消息。它是用户针对当前题目完成一次提交后,系统把这一轮的输入、评分、反馈、流程推进结果打包成的一条结构化记录。
一场面试里,用户回答了主问题 1、又回答了追问 1-F1、又回答了主问题 2,那通常会有 3 条 turn,而不是 1 条。
Turn Archive 和 Redis turns 记录的是同一类业务对象,但职责完全不同。Redis turn 是放在 interview:turns:session:{sessionId} 里的在线缓存列表,用来让当前请求快速读取最近几轮上下文,速度快但会过期、会裁剪、可能丢。Mongo turn 则是归档记录,额外带 sessionId、requestId、seq、snapshotVersion、createdAt 这些元信息,用来做持久化历史、幂等查重、顺序恢复和问题追查。
简单说,Redis turn 是「正在用的最近流水」,Mongo turn 是「正式入账的历史账本」。
恢复机制,Lazy Rehydrate
聊完数据放哪,接下来是状态丢了怎么办。
恢复机制的核心目标很简单,当某个 session 的在线运行态在 Redis 里缺失时,让当前请求还能继续获得可用状态。
这套方案里,恢复从来不是一个「离线修复动作」。更准确地说,它是当某个请求真正需要某个 session 的某类运行态,而当前 Redis 中这部分运行态又不完整时,系统就在这个请求进入业务主逻辑之前,先把它需要的运行态补齐。
恢复是由真正访问这个 session 的请求,按需触发的。只有请求拿不到这个数据的时候,才会去 Mongo 中获取数据,并且回写到 Redis 中。这就是懒加载的本质。
统一入口在 InterviewSessionRuntimeRehydrateService,核心方法是 ensureRuntime(...)。
java
public InterviewSessionRuntimeView ensureRuntime(
String sessionId,
InterviewRuntimeLoadMode loadMode,
InterviewRuntimeRehydrateScope scope) {
InterviewRuntimeRehydrateScope resolvedScope = scope == null
? InterviewRuntimeRehydrateScope.FULL_RUNTIME : scope;
if (StrUtil.isBlank(sessionId)) {
return buildView(loadMode, InterviewRuntimeRestoreSource.NONE,
InterviewRuntimeConfidence.READ_ONLY, false, null);
}
// 如果 Redis 已经 ready,直接复用
if (isRuntimeReady(sessionId, resolvedScope)) {
return buildView(loadMode, InterviewRuntimeRestoreSource.CACHE,
InterviewRuntimeConfidence.EXACT, false, getRuntimeSnapshot(sessionId));
}
RLock lock = null;
try {
lock = runtimeLockService.acquire(sessionId);
if (lock == null) {
// 没抢到锁,等待其他线程恢复完再复用
InterviewSessionRuntimeView reused = waitForRecoveredRuntime(sessionId, loadMode, resolvedScope);
if (reused != null) {
return reused;
}
lock = runtimeLockService.acquire(sessionId, FOLLOWER_RECHECK_MILLIS);
}
// 双重检查,可能已经被其他线程恢复了
if (isRuntimeReady(sessionId, resolvedScope)) {
return buildView(loadMode, InterviewRuntimeRestoreSource.CACHE,
InterviewRuntimeConfidence.EXACT, false, getRuntimeSnapshot(sessionId));
}
// 真正执行恢复
InterviewSessionRuntimeView rebuilt = rebuildRuntime(sessionId, loadMode, resolvedScope);
if (rebuilt != null) {
return rebuilt;
}
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
} catch (Exception ex) {
log.warn("Failed to ensure runtime, sessionId={}", sessionId, ex);
} finally {
runtimeLockService.release(lock);
}
return buildView(loadMode, InterviewRuntimeRestoreSource.NONE,
InterviewRuntimeConfidence.READ_ONLY, false, getRuntimeSnapshot(sessionId));
}
这段代码的关键点在于并发控制。如果 10 个请求同时撞到缺失状态,不是 10 个都去恢复。系统会在 session 维度只让一个线程恢复,其他线程等待,恢复完成后再继续业务。
恢复机制最终返回的不是简单的成功或失败,而是 InterviewSessionRuntimeView。这里面会告诉上层,当前恢复结果的 confidence、来源是 CACHE 还是 RUNTIME_SNAPSHOT 还是材料重建、这次有没有发生 cacheRebuilt、当前结果到底能不能继续写业务。
更新机制,Patch + CAS + 幂等补偿
恢复机制负责消费恢复材料,更新机制负责生产恢复材料。没有更新机制,恢复机制后面即使设计得再漂亮,也会因为恢复材料不完整、不准确而失去意义。
数据更新机制的核心目标是,当业务成功推进后,把最新运行态安全地沉淀成 Hot Snapshot + Cold Snapshot + Turn Archive,供后续恢复机制使用。
更新为什么复杂?因为它说到底在解决三件事情,高频更新不能太重、并发更新不能写乱、请求重试不能重复推进业务。对应解决手段是 Patch、CAS、Replay / 幂等补偿。
常见入口包括 refreshAfterAnswerCommitted(...)、refreshAfterQuestionExtraction(...)、refreshAfterDemeanorEvaluated(...)、refreshAfterFinalize(...),核心类在 InterviewSessionRuntimeSnapshotService。
这条链路大概可以概括成,业务成功后,提交一次 hot refresh request;HotRefreshCoordinator 先按 session 聚合和防抖;SnapshotService 读取当前 hot/cold snapshot;组装新的 HotPatch / ColdPatch;turns 需要时写入 Turn Archive;热快照落盘时走 CAS;如果发现是旧请求重试,还要能识别并做 replay / 幂等补偿。
为什么不能简单写成查对象、改字段、save?因为这里的状态不是普通 CRUD。它高频更新、并发更新、热冷数据变化频率不同、请求会重试、结果可能半成功。所以必须做成 Patch + CAS + Turn Archive + Replay / 幂等补偿。
闭环
这两条主线共同组成一个闭环,
业务推进成功
-> 更新机制写入检查点
-> Redis 继续承担在线态
-> 如果 Redis 丢了
-> 恢复机制读取检查点并回填 Redis
-> 业务继续推进
-> 再由更新机制写入新的检查点
没有更新机制,恢复机制没有材料可用。没有恢复机制,更新机制写下来的材料没有真正发挥线上兜底价值。
总结
回到开头那个问题。用户答完第 5 题,Redis 突然丢了。在新架构下,下一条请求进来时会先走 ensureRuntime(...)。发现 Redis 不完整,就会竞争 session 级锁,只有一个线程去 Mongo 里读 Hot Snapshot 和 Turn Archive,把当前流程、最近上下文、材料信息恢复出来,回写到 Redis,然后继续业务。
用户感知到的可能只是请求稍微慢了一点,而不是「我刚刚明明答完了,为什么系统又像没答一样」。
这套方案的核心不是幻想 Redis 永不失手,而是承认运行态会缺失,并提前设计好恢复入口、恢复依据和恢复边界。Redis 承担在线高频运行态,Mongo Snapshot 承担可恢复检查点,Turn Archive 承担历史轮次与回放依据,Lazy Rehydrate 承担按需恢复,Lock + Patch + CAS 承担并发保护。
如果你也在做长会话系统,不管是 AI 面试、在线客服、游戏房间还是工作流引擎,这套四层存储模型加两条主线的思路,应该都能直接套用。