AI 面试答题链路源码分析:Redis 幂等、题目锁与状态机如何配合

本文基于「码上面试平台」当前代码整理。文中的超时重试是用来检查链路的故障模型,不代表项目曾发生过对应线上事故。

一个答案提交,为什么不能只写成"调用模型再返回结果"?

假设用户提交答案后,服务端已经请求了评分模型,但浏览器迟迟没有收到响应。用户看到页面没动,再点一次提交。此时系统要处理的不只是第二次模型调用,还要回答几个问题:这是同一请求的重试吗?当前题目还没变吗?分数有没有已经入账?第一次请求的结果能不能直接返回?

如果这些判断散落在 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 返回成功""答题轮次落盘"和"运行态快照刷新"分开确认。

这条链路里我会记住的几个坑

  1. 不要把 session 状态和题目 flow 状态当成一个状态。 会话的 FINISHED 与题目流的 COMPLETED 代表不同层面的事实,收尾逻辑要同时检查。
  2. 不要只在锁前检查当前题。 锁前后有时间窗口,拿锁后要再核对题号。
  3. 不要把 AI 的追问字段直接当最终裁决。 当前题是否为追问、次数上限和规则引擎都会影响后续流程。
  4. 不要把请求幂等、题目锁和 Single-flight 混成一个功能。 它们的 key、保护对象和故障行为都不同。
  5. 不要把补偿称为严格事务。 分布式系统里更实用的问题是:每个失败窗口能否重试、能否回放、是否会重复计分,以及状态是否可恢复。

小结:这不是"加三把锁",而是定义三种重复

这段实现给我的启发是,处理重复请求之前,先说清楚重复的是什么:同一业务请求、同一道题上的并发写入,还是同一次模型计算。把这三个对象分开,代码职责和排障入口就清楚很多。

但保护机制越多,越需要从前端一路读到存储层。尤其 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 分布式锁 幂等性 源码分析

相关推荐
阿里云大数据AI技术1 小时前
阿里云Flink + Fluss年度发布:Streaming Agent 与湖流一体
人工智能·flink·agent
ZealSinger1 小时前
Boot4升后session_redis属性怎么改名
redis·springsession·boot4·升级踩坑
Java后端的Ai之路1 小时前
【AIAgent项目-AI生成PPT-09】灵活排版
前端·vue.js·人工智能·python·powerpoint
Mininglamp_27181 小时前
Human-AI Co-Writing With Full Accept/Reject Control on Every Edit
人工智能
X54先生(人文科技)1 小时前
ELR-SELLM_硅基伙伴通过对话碰撞个性涌现的推理架构_v10
人工智能·深度学习·架构·零知识证明
CV爱数码1 小时前
论文精读|RED-CNN:残差编解码网络,低剂量 CT 去噪的经典基线
网络·人工智能·算法·cnn·ct·重建·x-ray
Sailing1 小时前
【封闭结束3】ReAct 循环:从一次 Function Call 到多步 Agent
人工智能·面试·agent
AIyy8662 小时前
AI任务在关机后继续运行的三种实现思路
人工智能
万粉变现经纪人2 小时前
如何解决 pip install 安装报错 检测到 yanked 版本需更换可用版本 问题
人工智能·python·scrapy·numpy·pandas·pyqt·pip