长会话状态治理(下):数据更新机制、并发保护与可复用设计原则
系列导航 :本文是长会话状态治理系列的下篇,聚焦于"业务成功后怎么安全写下检查点"以及可复用的设计原则。上篇讲解了问题分析、存储分层设计和恢复机制的完整实现,见 长会话状态治理(上)。
一、回顾与本篇定位
在上篇中,我们分析了长会话运行态为什么天然脆弱,介绍了 Redis + Hot Snapshot + Cold Snapshot + Turn Archive 的三层存储分层设计,并深入剖析了以 ensureRuntime(...) 为核心的恢复机制------当 Redis 运行态缺失时,如何从检查点材料中安全地恢复会话状态。
但恢复机制只是闭环的一半。用一句话来概括两条主线的关系:
更新机制负责生产恢复材料,恢复机制负责消费恢复材料。
没有更新机制持续产出高质量的检查点,再好的恢复入口也巧妇难为无米之炊。本篇将深入讲解数据更新机制的完整实现------从触发时机、防抖聚合、CAS 重试循环,到 Patch 差量更新、单调性校验、幂等补偿,再到轮次归档与软回放幂等的详细设计,最后提炼出适用于任何长会话系统的七条可复用原则。
二、数据更新机制:业务成功后,怎么安全写下检查点
2.1 核心目标
数据更新机制的核心目标是:
当业务成功推进后,把最新状态安全写成可恢复检查点,供后续恢复链路使用。
它不是简单的"save 一下对象",因为这里的状态不是普通 CRUD。它面对的是高频更新、并发更新、热冷数据变化频率不同、请求会重试、结果可能半成功这些现实挑战。
2.2 触发时机
更新机制的入口分布在关键业务节点之后:
| 入口方法 | 触发时机 | 快照级别 | 是否强制刷盘 |
|---|---|---|---|
refreshAfterQuestionExtraction(sessionId) |
出题完成 | QUESTION_READY |
否(可防抖) |
refreshAfterDemeanorEvaluated(sessionId) |
神态评分完成 | ACTIVE |
否 |
refreshAfterAnswerCommitted(sessionId, requestId, turnLog) |
答题提交成功 | ACTIVE |
是(强制刷盘) |
refreshAfterFinalize(sessionId) |
面试结束 | FINALIZED |
是(强制刷盘) |
每个入口都会构造一个 HotRefreshRequest,携带触发类型、requestId 和已提交的 turnLog:
java
// 答题提交成功时:
public static HotRefreshRequest answerCommitted(String sessionId, String requestId, InterviewTurnLog turn) {
return HotRefreshRequest.builder()
.sessionId(sessionId)
.trigger(ANSWER_COMMITTED)
.snapshotLevel("ACTIVE")
.requestId(requestId)
.committedTurn(turn)
.persistTurnArchive(true) // 需要归档
.forceFlush(true) // 强制立即刷盘
.build();
}
// 出题完成时:
public static HotRefreshRequest questionReady(String sessionId) {
return HotRefreshRequest.builder()
.sessionId(sessionId)
.trigger(QUESTION_READY)
.snapshotLevel("QUESTION_READY")
.persistTurnArchive(false) // 不需要归档
.forceFlush(false) // 可以防抖
.build();
}
注意 forceFlush 的区别:答题提交和面试结束是关键检查点,必须立即刷盘;出题完成等中间态可以延迟合并,减少写压力。
2.3 HotRefreshCoordinator:防抖与聚合
每次业务成功后,不是直接去写 Mongo,而是先把刷新意图提交给 HotRefreshCoordinator。这个 Coordinator 做了三件事:
1. 按 session 聚合:同一 session 在短时间内多次刷新意图会被合并到一个 Bucket 中。
2. 防抖延迟:普通中间态刷新会延迟一个 debounce window(默认 150ms),等待后续意图合并。最大聚合窗口默认为 500ms,确保不会无限延迟。
3. 关键检查点立即刷盘 :forceFlush 标记的刷新会跳过防抖,等待当前 flush 完成后立即执行。
submit(request)
│
├── Coordinator 未启用 → 直接 flush(同步执行)
│
├── forceFlush = true(答题提交、面试结束等)
│ └── 合并到 Bucket
│ └── 如果当前有 flush 正在执行:
│ │ └── 等待(最多 600ms),直到当前 flush 完成
│ └── 立即执行 flushBucket()
│
└── forceFlush = false(出题完成等中间态)
└── 合并到 Bucket
└── 如果当前没有 flush 正在执行:
└── 延迟 debounceWindow(150ms)后调度执行
Bucket 内部通过 synchronized 保证线程安全,merge 逻辑会把多次刷新的信息合并:保留最新的 snapshotLevel、最新的 requestId 和 turnLog,合并 persistTurnArchive 标记。
Coordinator 内部用 ConcurrentHashMap<sessionId, Bucket> 管理所有活跃 session 的刷新意图。当某个 session 的所有刷新都已完成且没有新的意图时,对应的 Bucket 会被清理,避免内存泄漏。
2.4 refreshSnapshot:刷新核心流程
refreshSnapshot(...) 是数据更新机制的真正核心。它的完整执行流程如下:
refreshSnapshot(sessionId, snapshotLevel, requestId, committedTurn, persistTurnArchive)
│
├── Step 1: 加载当前状态
│ ├── 从 DB 加载 InterviewSession
│ ├── 从 DB 加载 InterviewQuestion
│ ├── 从 Mongo 加载当前 Hot Snapshot
│ └── 从 Mongo 加载当前 Cold Snapshot
│
├── Step 2: 进入 CAS 重试循环(最多 3 次)
│ │
│ ├── Step 2a: 从 Redis + DB 组装最新状态
│ │ ├── resolveQuestions(): 优先 Redis → 降级 DB
│ │ ├── resolveSuggestions(): 优先 Redis → 降级 DB
│ │ ├── resolveResumeContext(): 优先 Redis → 降级 DB
│ │ ├── resolveTurns(): Redis → Archive → Snapshot → committedTurn
│ │ ├── resolveFlow(): Redis → 终态推导 → turns 推导 → 初始 Flow
│ │ └── resolveScoreAggregate(): 从 turns 重新计算 sum/count
│ │
│ ├── Step 2b: 归档当前轮次(如果需要)
│ │ └── archiveTurn():
│ │ ├── 先检查同一 requestId 是否已归档(幂等)
│ │ ├── 如果已归档,返回已有 seq
│ │ └── 否则追加一条 Turn Archive,seq 单调递增
│ │
│ ├── Step 2c: 构建 HotPatch(增量更新载体)
│ │ └── buildHotPatch():
│ │ ├── snapshotVersion: current + 1
│ │ ├── flow / scoreAggregate / recentTurns (上限 20)
│ │ ├── archiveWatermark / lastTurnSeq
│ │ ├── lastMutationId / lastAppliedRequestId
│ │ └── lastCommittedQuestionNumber / lastCommittedTurnDigest
│ │
│ ├── Step 2d: 三重保护检查
│ │ ├── shouldSkipHotPatch? → 所有字段一致,跳过写入
│ │ ├── seedHotSnapshot? → 快照不存在,初始化种子
│ │ ├── isMutationAlreadyApplied? → 同一 requestId 已处理,跳过
│ │ └── validateHotPatchMonotonicity → 单调性校验
│ │
│ ├── Step 2e: CAS 写入
│ │ └── compareAndSetPatch(sessionId, expectedVersion, patch)
│ │ ├── 成功 → 跳出循环
│ │ └── 失败 → 检查最新版本是否已包含同一 mutationId
│ │ ├── 是 → 幂等命中,安全跳过
│ │ └── 否 → backoff 后重试
│ │
│ └── Step 2f: CAS 重试退避
│ └── backoff = baseDelay × (attempt + 1) + random(10~30ms)
│
├── Step 3: 条件触发冷快照更新
│ └── applyColdSnapshotIfNecessary():
│ ├── 如果 snapshotLevel == ACTIVE 且冷快照已存在 → 跳过
│ └── 否则写入冷快照(questions/suggestions/resumeContext 等)
│
└── Step 4: 返回结果
├── 成功 → true
└── CAS 重试耗尽 → false(打 warn 日志)
这里有几个值得注意的设计决策:
为什么每轮都重新组装状态? 因为在 CAS 重试循环中,当前线程可能在等待期间被其他线程"抢先"更新了快照。每次循环都重新从 Redis 和 DB 读取最新数据,确保构建出来的 Patch 是基于最新版本的状态。
为什么 recentTurns 限制为 20 轮? 热快照的 recentTurns 只需要保留最近 N 轮用于快速恢复上下文。全量历史由 Turn Archive 承担。这避免了热快照文档随面试推进无限膨胀。
2.5 Patch 而非整包覆盖
每次更新不是把整个文档重写,而是通过 HotPatch / ColdPatch 做字段级增量更新。
以 HotSnapshotRepositoryImpl 为例,它构建的 Mongo Update 对象只 set 有值的字段:
java
private Update buildUpdate(String sessionId, HotPatch patch, Date now, boolean withOnInsert) {
Update update = new Update();
if (withOnInsert) {
update.setOnInsert("sessionId", sessionId); // upsert 时设置
update.setOnInsert("createTime", now);
}
// 逐字段按需 set
if (patch.getFlow() != null) update.set("flow", patch.getFlow());
if (patch.getScoreAggregate() != null) update.set("scoreAggregate", patch.getScoreAggregate());
if (patch.getRecentTurns() != null) update.set("recentTurns", patch.getRecentTurns());
if (patch.getArchiveWatermark() != null) update.set("archiveWatermark", patch.getArchiveWatermark());
if (patch.getLastMutationId() != null) update.set("lastMutationId", patch.getLastMutationId());
// ... 其余字段同理
update.set("updateTime", now);
return update;
}
冷快照的 Patch 逻辑类似,但更新的字段是低频材料(questions / suggestions / resumeContext / demeanorScore 等),并且采用 upsert 语义------如果文档不存在则创建,存在则按字段更新。
Patch 的好处:
- 避免并发场景下"后写覆盖前写"------每次 Patch 只更新变化的字段,不触碰其他字段
- 减少 Mongo 写入量------不需要每次都把整个文档序列化并传输
- 天然支持热冷分层------热快照和冷快照独立 Patch,互不干扰
2.6 CAS 并发保护
热快照的更新采用 Compare-And-Set 语义,这是保证并发安全的核心:
java
public boolean compareAndSetPatch(String sessionId, Long expectedVersion, HotPatch patch) {
Query query = Query.query(
Criteria.where("sessionId").is(sessionId)
.and("snapshotVersion").is(expectedVersion) // CAS 条件
);
Update update = buildUpdate(sessionId, patch, now, false);
UpdateResult result = mongoTemplate.updateFirst(query, update, HotSnapshot.class);
return result.getModifiedCount() > 0;
}
只有当前版本号(snapshotVersion)与预期一致时才会写入成功。如果并发请求导致版本号已经前进,CAS 会失败,随后触发退避重试:
| 参数 | 值 | 说明 |
|---|---|---|
| 最大重试次数 | 3 | 避免无限重试 |
| 基础退避 | 20ms × (attempt + 1) | 线性递增 |
| 随机抖动 | 10~30ms | 避免多个线程同时重试(惊群效应) |
CAS 保证了:即使多个线程同时在刷新热快照,也不会出现"旧版本覆盖新版本"的情况。
为什么冷快照不需要 CAS? 冷快照的更新频率低(出题完成、神态评分完成等关键节点),并发冲突概率小。而且冷快照更新采用的是 upsert + 字段级 Patch,即使并发写入也不会互相覆盖(只是可能重复设置相同值)。这是一种有意识的简化------用最小的复杂度覆盖最大的风险。
2.7 单调性保护
在 CAS 之前,还有一层 validateHotPatchMonotonicity 校验,确保关键业务指标不会回退:
java
private void validateHotPatchMonotonicity(HotSnapshot current, HotPatch patch) {
// 1. 轮次序号不能回退
if (patch.getLastTurnSeq() < current.getLastTurnSeq())
throw new IllegalStateException("hot snapshot lastTurnSeq regressed");
// 2. 归档水位不能回退
if (patch.getArchiveWatermark() < current.getArchiveWatermark())
throw new IllegalStateException("hot snapshot archiveWatermark regressed");
// 3. 计分次数不能减少
if (patch.getScoreAggregate().getScoreCount() < current.getScoreAggregate().getScoreCount())
throw new IllegalStateException("hot snapshot scoreCount regressed");
// 4. 流程进度不能倒退(非终态下)
if (!current.getFlow().isCompleted()
&& patch.getFlow().getCurrentIndex() < current.getFlow().getCurrentIndex())
throw new IllegalStateException("hot snapshot flow index regressed");
}
如果检测到回退,直接抛出 IllegalStateException,阻止脏数据写入。这层校验相当于在 CAS 的"版本号一致性"之上,又加了一层"业务语义一致性"的保护。
为什么需要两层保护? CAS 只能保证"我的更新是基于最新版本做的",但不能保证"我构建出来的 Patch 内容本身是正确的"。比如,某个旧请求在重试时基于过期的 Redis 数据构建了一个 flow,虽然 CAS 会拦住它(版本号不对),但如果恰好版本号一致(极端场景),单调性校验就是最后一道防线。
2.8 幂等补偿
热快照中记录了 lastMutationId(通常是 requestId)。在刷新前和 CAS 失败后都会检查:
java
private boolean isMutationAlreadyApplied(HotSnapshot hotSnapshot, String mutationId) {
return StrUtil.equals(hotSnapshot.getLastMutationId(), mutationId);
}
第一次检查 (CAS 之前):如果当前快照的 lastMutationId 已经等于本次 requestId,说明之前的请求已经成功写入,直接跳过。
第二次检查 (CAS 失败之后):如果 CAS 失败,重新加载最新版本的快照,再做一次 lastMutationId 检查。如果最新版本的 mutationId 与当前请求一致,说明另一个并发线程已经成功写入了同一个变更,当前请求可以安全跳过。
这保证了:即使前端超时重试,同一个 requestId 不会导致状态被重复推进。
2.9 跳过快照更新
当新旧快照的所有关键字段完全一致时,系统会跳过无意义的写入:
java
private boolean shouldSkipHotPatch(HotSnapshot current, HotPatch patch) {
return Objects.equals(current.getUserId(), patch.getUserId())
&& StrUtil.equals(current.getSessionStatus(), patch.getSessionStatus())
&& Objects.equals(current.getFlow(), patch.getFlow())
&& Objects.equals(current.getScoreAggregate(), patch.getScoreAggregate())
&& Objects.equals(current.getRecentTurns(), patch.getRecentTurns())
// ... 全部字段对比
;
}
这避免了在防抖窗口合并后、或重试场景下执行完全重复的 Mongo 写入。虽然 Mongo 的 upsert 本身是幂等的,但跳过无意义的写入可以减少 IO 开销和版本号递增。
三、轮次归档与软回放幂等
3.1 Turn Archive 的设计
每次答题成功提交后,除了更新热快照的 recentTurns,还会向 Turn Archive 追加一条归档记录。归档实体结构非常精简:
java
@Document(collection = "interview_session_turn_archive")
public class InterviewSessionTurnArchive {
@Id private String id;
@Indexed private String sessionId; // 会话标识
@Indexed private String requestId; // 请求标识(幂等键)
@Indexed private Long seq; // 单调递增序号
private Long snapshotVersion; // 关联的快照版本
private InterviewTurnLog turnPayload; // 完整轮次数据
@CreatedDate private Date createdAt;
}
Turn Archive 是全量追加、永不修改的。它承担两个职责:
- 全量历史回放 :恢复时可以按
seq升序加载所有轮次,得到完整的面试对话历史 - 软回放幂等 :通过
requestId判断某个请求是否已经成功处理过
归档写入本身也做了幂等保护:
java
private Long archiveTurn(String sessionId, String requestId, InterviewTurnLog turn, Long version) {
// 先检查同一 requestId 是否已归档
if (StrUtil.isNotBlank(requestId)) {
Optional<TurnArchive> existing = turnArchiveRepo.findBySessionIdAndRequestId(sessionId, requestId);
if (existing.isPresent()) {
return existing.get().getSeq(); // 已归档,直接返回已有 seq
}
}
// 否则追加新归档,seq 从最大 seq + 1 开始
long nextSeq = turnArchiveRepo.findFirstBySessionIdOrderBySeqDesc(sessionId)
.map(a -> a.getSeq() + 1L).orElse(1L);
// ... 保存
return nextSeq;
}
3.2 软回放幂等的三重匹配
当重复请求进来时,findReplayResponse(...) 会通过三重机制识别重复:
findReplayTurn(snapshot, requestId, questionNumber, answerContent)
│
├── 第一重:按 requestId 匹配 recentTurns
│ └── 从后往前遍历 recentTurns,查找 requestId 一致的 turn
│ └── 命中 → 直接回放该 turn 的结果
│
├── 第二重:按 turnDigest 匹配 recentTurns
│ └── SHA256(题号 + "|" + 答案前1000字符)
│ └── 从后往前遍历 recentTurns,查找 digest 一致的 turn
│ └── 命中 → 直接回放
│
└── 第三重:按 lastCommittedTurnDigest 匹配 Archive
└── 如果热快照的 lastCommittedTurnDigest 与当前请求一致
└── 加载 Turn Archive 的最后一条记录
└── 命中 → 回放该归档的结果
三重匹配的设计考虑了不同的降级场景:
- 第一重依赖 requestId,最常见也最可靠
- 第二重依赖内容摘要,在 requestId 丢失时兜底
- 第三重依赖热快照的摘要字段 + 归档,在 recentTurns 被截断时兜底
其中 turnDigest 的计算方式值得注意------它取题号和答案内容的前 1000 个字符做 SHA256:
java
private String buildTurnDigest(String questionNumber, String answerContent) {
return DigestUtil.sha256Hex(normalizeQuestionNumber(questionNumber) + "|" + truncateAnswer(answerContent));
}
private String truncateAnswer(String answerContent) {
return answerContent == null ? "" :
answerContent.length() <= 1000 ? answerContent : answerContent.substring(0, 1000);
}
截取 1000 字符是为了平衡匹配精度和性能。在正常业务场景下,同一个题号 + 同一个用户的答案内容前 1000 字符已经足够唯一。
3.3 回放响应的构建
一旦匹配到已处理的 turn,系统会直接从 turn 的数据构建完整响应,而不是重新执行答题链路:
java
private InterviewAnswerRespDTO buildReplayResponse(InterviewTurnLog turn) {
InterviewAnswerRespDTO response = InterviewAnswerRespDTO.init();
response.withCurrentQuestion(turn.getQuestionNumber(), turn.getQuestionContent());
response.withEvaluation(turn.getScore(), turn.getFeedback(), turn.getTotalScore());
if (Boolean.TRUE.equals(turn.getFinished())) {
response.finish().success();
return response;
}
response.withNextQuestion(
turn.getNextQuestionNumber(), turn.getNextQuestion(),
isFollowUp, followUpCount
).success();
return response;
}
这保证了:即使用户因为网络超时重试,系统也不会重新评估答案、重新推进流程、重新计分------而是精准地回放上一次的结果。
四、架构总览
将上下两篇讲解的所有组件放在一起,整个状态治理体系的协作关系如下:
┌──────────────────────────┐
│ 业务请求入口 │
│ (Answer Pipeline 等) │
└────────────┬─────────────┘
│
┌──────────▼──────────┐
│ ensureRuntime(...) │ ◄── 恢复机制入口(上篇)
│ (RehydrateService) │
└──────────┬──────────┘
│
┌───────────────┴───────────────┐
│ isRuntimeReady(scope)? │
├── YES ──► CACHE + EXACT │
└── NO │
│ │
┌────────▼────────┐ │
│ 分布式锁竞争 │ │
└────────┬────────┘ │
┌──────┴──────┐ │
Owner│ │Follower │
│ │ │
┌────────▼───┐ ┌─────▼────────┐ │
│ 优先 Snapshot│ │ 轮询等待 │ │
│ 恢复 │ │ (4×80ms) │ │
└──────┬──────┘ └─────┬────────┘ │
│ │ │
┌──────▼───┐ │ │
│ 降级材料 │ │ │
│ 推导恢复 │ │ │
└──────┬───┘ │ │
│ │ │
▼ ▼ │
写回 Redis + 返回 RuntimeView │
│
──────────── 业务继续推进 ──────────────────────── │
│
┌──────────────────────────┐ │
│ 业务成功后触发刷新 │ │
│ refreshAfterXxx(...) │ ◄┘ 更新机制入口(本篇)
└────────────┬─────────────┘
│
┌──────────▼──────────┐
│ HotRefreshCoordinator│ ◄── 防抖 & 聚合
│ (按 session 聚合) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ refreshSnapshot(...) │
│ ┌─────────────────┐ │
│ │ Patch + CAS │ │
│ │ + 单调性校验 │ │
│ │ + 幂等补偿 │ │
│ └─────────────────┘ │
└──────────┬──────────┘
│
┌────────────┼────────────┐
│ │ │
Hot Snapshot Cold Snapshot Turn Archive
(CAS 更新) (按需更新) (追加写入)
五、设计总结:可复用的七条核心原则
从这套实现中,可以提炼出适用于任何长会话系统的通用原则:
原则一:承认运行态会缺失,提前设计好恢复入口
不要幻想 Redis 永不失手。只要会话足够长、请求足够多,状态缺口几乎必然出现。关键是提前准备好:
- 恢复入口 :统一的
ensureRuntime(...),所有业务请求必须先过这一关 - 恢复依据:Hot Snapshot + Cold Snapshot + Turn Archive 三级检查点
- 恢复边界:Confidence 告诉你恢复结果可不可靠,Scope 告诉你恢复范围够不够
原则二:热冷分层,降低写放大
高频变化的状态(flow、score、turns)和低频变化的材料(questions、resume context)分开存储。每次业务推进只更新热快照,避免无谓的冷数据重写。热快照走 CAS 精细控制,冷快照走 upsert 宽松更新。
原则三:差量更新(Patch)代替整包覆盖
用字段级 Patch 代替整个文档 rewrite。好处是:
- 避免并发场景下"后写覆盖前写"
- 减少 Mongo 写入量和网络传输
- 天然支持热冷分层------热快照和冷快照独立 Patch
原则四:CAS + 单调性校验双重并发保护
CAS 保证版本号一致性,单调性校验保证业务语义一致性。两者结合,即使多个线程同时在刷新热快照,也不会出现"旧版本覆盖新版本"或"状态回退"的情况。
原则五:幂等补偿兜底重试场景
通过 lastMutationId + requestId + turnDigest 三重机制识别重复请求,保证超时重试不会导致状态被重复推进。在 CAS 失败后也做一次幂等检查,避免误判为"版本冲突"而错误重试。
原则六:Owner-Follower 避免并发恢复浪费
同一 session 的并发恢复请求只需要一个 owner 执行恢复,其他 follower 等待并复用结果。这在高并发场景下尤为重要------它避免了重复的 Mongo 查询、Redis 写入和状态计算。
原则七:恢复结果带置信度,上层按级处理
恢复机制不返回简单的 boolean,而是返回带有 Confidence 和 RestoreSource 的视图对象。上层业务可以根据置信度决定是否继续写入:
EXACT/DERIVED→ 可以继续推进业务READ_ONLY→ 只能提供查询,不能推进TERMINAL→ 会话已结束,直接回放
这避免了在不可靠的状态上做出不可逆的业务决策。
六、写在最后
这套方案最终解决的问题不是"永远不丢状态",而是**"丢了也能恢复"**。
它把一个看似不可能完成的任务------保证长会话运行态永远完整------转化成了一个可工程化落地的方案:
只要检查点还在,恢复入口就能把记忆找回来,让这场会话正确地走完。
从一个更高的视角来看,这套方案的本质不是某一个技术点的巧妙运用,而是一种对长会话状态本质的认知升级:
- 长会话运行态是一种高脆弱、高时序、高并发敏感的数据形态
- 不能用"普通缓存思路"去管理它
- 必须建立一套可恢复的长会话状态治理体系------热层承接高频读写,持久层沉淀恢复材料,懒恢复入口保障兜底,并发保护防止写乱,幂等补偿兜底重试
理解了这一点,热冷分层、懒恢复、Patch + CAS、单调性校验和幂等补偿,就不再是一堆零散的技术技巧,而是一套完整方案中不可或缺的组成部分。