本文基于「码上面试平台」当前代码整理。文中的超时重试是用来检查链路的故障模型,不代表项目曾发生过对应线上事故。
一个答案提交,为什么不能只写成"调用模型再返回结果"?
假设用户提交答案后,服务端已经请求了评分模型,但浏览器迟迟没有收到响应。用户看到页面没动,再点一次提交。此时系统要处理的不只是第二次模型调用,还要回答几个问题:这是同一请求的重试吗?当前题目还没变吗?分数有没有已经入账?第一次请求的结果能不能直接返回?
如果这些判断散落在 Controller、缓存操作和模型调用之间,就很容易出现重复计分、题目跳转或"明明成功了,重试却报错"的体验问题。
我读这条链路时,先把它拆成三个不同的并发问题:
- 同一个业务请求重复到达,应该回放之前的结果。
- 同一题被不同请求同时提交,应该串行处理。
- 同一份 AI 输入被多个节点同时计算,应该尽量只调用一次模型。
这三个问题分别由请求幂等、题目级锁和 AI Single-flight 处理。它们不是同一把锁,也不应该共用一个含糊的"防重"概念。
从入口顺一遍答题流水线
后端主入口在 InterviewAnswerPipeline.execute。按实际顺序,链路大致是:
text
参数校验
-> 归一化 requestId
-> 请求幂等检查 / 历史结果回放
-> 恢复可写的面试运行态
-> 校验当前题号
-> 获取 sessionId + questionNumber 题目锁
-> 加锁后重新校验题号
-> 调用评分 Agent 并解析结构化结果
-> LiteFlow / 追问规则决定下一步
-> 提交分数并推进题目状态
-> 记录答题轮次、登记幂等结果、刷新运行态快照
-> 释放题目锁
入口类把流程拆成多个 step... 方法,这一点对阅读代码很有帮助:不要只追 evaluateAnswer,还要看模型调用前后状态如何变化,以及失败时 finally 做了什么。
第一层:请求幂等解决"同一个请求又来了"
normalizeRequestId 优先使用客户端传入的 requestId。没有传时,后端会用 sessionId + questionNumber + answerContent 生成一个摘要 ID。随后 InterviewAnswerIdempotencyService 在 Redis 里检查两类记录:
processing:请求正在处理,重复到达时返回处理中。replay:请求已经成功,重复到达时直接返回之前保存的响应。
这层的核心收益不只是少调一次模型,而是把"重复提交同一业务动作"变成"重复读取同一业务结果"。
不过,幂等键依赖 requestId 的生命周期。前端 useInterviewSessionFlow 在每次调用提交函数时生成一个新的 ID;interviewService 的 JSON 请求失败后回退到 multipart 请求时,会继续使用同一个参数对象,因此这次传输兜底可以复用 ID。若用户重新触发一次提交,则会生成新的 ID。也就是说,若产品需要把用户主动重试也识别为同一逻辑请求,就要在重试期间保存并复用首次的 ID;只在后端加 Redis 幂等键并不能替客户端决定"这是不是同一次操作"。
前端 service 还有一层短时间窗口内的请求去重:按会话和题号生成 key,并配置 250ms debounce。它能改善同一页面的快速连点体验,但它和服务端 requestId 幂等不是一个边界,不能替代服务端在超时、跨标签页或多实例下的判断。
另一个值得注意的边界是:后端没有收到 requestId 时会用题号和答案生成默认 ID。这能覆盖简单重放,但同一题相同答案也会得到相同 ID。若业务允许用户明确要求重新评估同一份答案,客户端应显式创建新的逻辑请求 ID,而不是依赖默认值。
第二层:题目锁解决"不同请求同时改同一题"
InterviewQuestionLockService 使用 Redisson,锁 key 由 sessionId 和题号组成。它与 requestId 无关:同一题即使被不同 requestId 提交,也会竞争同一个题目锁。
代码还有一个容易漏看的细节:拿到锁以后会重新读取 flow 并校验题号。锁前检查只能说明"刚才看见的当前题"匹配请求;等待锁期间,另一请求可能已经推进题号。锁后复查是在保护题目游标,防止旧答案落到新状态上。
锁的粒度也体现了一个取舍:同一会话的不同题理论上可以并行,但正常面试流程本来就按题号顺序推进,所以实现选择按题串行。真正需要关注的是锁等待时间和 AI 调用耗时,因为从评分到流程落盘这段工作发生在题目锁保护范围内。
第三层:AI Single-flight 解决"同一份输入重复计算"
评分服务最终通过 InterviewAiInvoker 调用 DistributedInterviewAiSingleFlightService。评分 key 会包含阶段、会话、题号和答案摘要;满足同 key 的调用可以等待并复用已有结果。它保护的是模型调用,不负责把整条答题业务提交成原子操作。
这一区分很重要:请求幂等保存的是业务响应,题目锁保护的是 flow 和分数等共享状态,Single-flight 复用的是 AI 计算结果。即使模型结果被复用,业务层仍要决定能否推进题目、是否计分、轮次记录是否写入。
AI 给了追问建议,为什么还要规则链?
评分结果里包含 follow_up_needed 等字段,但 InterviewAnswerPipeline 会把 AI 建议、当前是否已是追问、追问次数、最大追问次数、分数和遗漏要点组装成规则上下文,再交给 InterviewFollowUpRuleService。规则服务执行 LiteFlow 链;规则链异常时还会按配置回落到默认策略。
我更认可把模型输出当成输入,而不是直接当成业务命令。模型可以提出"建议追问",但业务规则还得检查次数上限和当前流程状态。这样做会多一层代码,却让流程裁决更容易解释,也更容易在不改提示词的情况下调整业务条件。
分数提交与状态推进为什么要一起看?
主问题答案的分数只在成功分支提交;追问题目不会再次累计到总分。推进题号前会复制一份 flow 状态。如果分数提交失败,代码会尝试恢复这份状态,让客户端重试仍然对着原题。
这不是数据库事务意义上的原子提交。flow、Redis 分数聚合、Redis 轮次记录和 MongoDB 快照通过不同调用步骤更新,代码用回滚、幂等回放和补偿任务把失败窗口收窄。读源码时我会特别区分"代码有补偿路径"和"所有副作用严格原子"这两句话,它们不是一回事。
这里还有一个我会优先验证的细节:流水线的 commitScoreAtSuccess 只有在调用抛出异常时才返回失败并触发 flow 回滚;但 InterviewQuestionCacheServiceImpl.addSessionScore 会捕获异常、记录日志,再返回当前总分。这样一来,某些 Redis 写入失败可能被下层转换成普通返回值,外层就继续按成功路径推进。需要结合 Redis 故障测试确认调用方能否区分"分数确实写入"和"读取到旧分数",不能只因为存在 rollback 方法就认定失败回滚完整。
例如,追问分支先把追问题目写入缓存并推进 flow,之后才提交分数;若提交失败,当前实现会恢复 flow。复盘时我会继续核对追问题目缓存是否也需要清理或重建,以及重试能否得到一致的结果。仅凭 flow rollback 这段代码,不应该直接断言整条分支已经完全回滚。
成功收尾时,finishAndReturn 会写入答题轮次;如果写入失败,会安排 repair;然后保存幂等响应并提交运行态快照刷新。快照刷新通过协调器提交,具体持久化还要继续看刷新任务和 Mongo CAS 更新。因此,阅读这类代码要把"API 返回成功""答题轮次落盘"和"运行态快照刷新"分开确认。
这条链路里我会记住的几个坑
- 不要把 session 状态和题目 flow 状态当成一个状态。 会话的
FINISHED与题目流的COMPLETED代表不同层面的事实,收尾逻辑要同时检查。 - 不要只在锁前检查当前题。 锁前后有时间窗口,拿锁后要再核对题号。
- 不要把 AI 的追问字段直接当最终裁决。 当前题是否为追问、次数上限和规则引擎都会影响后续流程。
- 不要把请求幂等、题目锁和 Single-flight 混成一个功能。 它们的 key、保护对象和故障行为都不同。
- 不要把补偿称为严格事务。 分布式系统里更实用的问题是:每个失败窗口能否重试、能否回放、是否会重复计分,以及状态是否可恢复。
小结:这不是"加三把锁",而是定义三种重复
这段实现给我的启发是,处理重复请求之前,先说清楚重复的是什么:同一业务请求、同一道题上的并发写入,还是同一次模型计算。把这三个对象分开,代码职责和排障入口就清楚很多。
但保护机制越多,越需要从前端一路读到存储层。尤其 requestId 由谁创建、失败后是否复用、哪些写入同步完成、哪些快照异步刷新,这些边界比"用了 Redis 还是 MongoDB"更直接地决定重试时用户会看到什么。
下一篇可以继续沿着恢复链路往下读:Redis 里的运行态不见了,系统如何判断现有快照足不足以恢复成可写状态?
源码入口
- 后端答题流水线:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/flow/answer/InterviewAnswerPipeline.java - 请求幂等:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/flow/answer/InterviewAnswerIdempotencyService.java - 题目锁:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/flow/answer/InterviewQuestionLockService.java - 状态机:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/application/flow/InterviewFlowStateMachine.java - 分数缓存边界:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/service/impl/InterviewQuestionCacheServiceImpl.java、admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/service/impl/InterviewScoreServiceImpl.java - AI 调用入口:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/shared/InterviewAiInvoker.java - 追问规则:
admin/src/main/java/com/hewei/hzyjy/xunzhi/interview/application/rule/InterviewFollowUpRuleService.java - 前端提交入口:useInterviewSessionFlow.ts
发布标签
Java Spring Boot Redis 分布式锁 幂等性 源码分析