Java异步AI业务

项目目录

项目简介

InterviewGuide 是基于 Java、Spring Boot 与 Spring AI 的面试训练平台,提供简历分析、模拟面试、知识库练习和评估报告。简历分析以 Redis Stream 异步执行,通过数据库状态和执行代次协调领取、恢复与结果保存。

如何写进简历

面伴 · 校园求职训练平台 | 场景化示例

项目描述:基于 InterviewGuide,面向应届生与就业指导老师提供简历分析、模拟面试及训练记录,跟踪多轮修改与薄弱项。

技术栈:Java、Spring Boot、Spring AI、PostgreSQL、Redis

主要工作:

  • 异步分析与中断恢复:基于 Redis Streams 消费组接入简历分析,以数据库保存任务状态,结合待确认消息补领和超时巡检恢复中断任务,使上传请求与耗时模型分析解耦。
  • 旧执行结果隔离:利用状态条件更新和 attemptId 约束任务领取、阶段心跳与完成操作;结果保存时锁行复核执行代次,拦截超时重领后旧消费者的写回。
  • 结构化评价与解析重试:基于 Java record 和 Schema 接入结构化评分与改进建议,通过统一调用器处理格式校验和有限重试,将模型输出转为可保存、可展示的分析记录。

面试题与回答

  1. 用 Redis 作消息队列时,如何处理宕机导致的消息丢失?

    展开收起

    核心回答

    InterviewGuide 用 Java 和 Spring 实现简历分析等异步任务,Redis Stream 负责投递,数据库保存正文、结果与任务状态。以简历分析为例,先保存 PENDING 任务,再发送包含 resumeId 和 retryCount 的消息;消费者先领取数据库任务,执行分析、保存结果并更新完成状态,最后确认消息。

    恢复有两层:Stream 层主动补领未确认消息,数据库层扫描长期卡住的任务并补投。 前者解决消息交给某个消费者后无人继续处理的问题,后者为消息丢失、裁剪或业务状态停滞提供恢复依据。它允许重投,依靠业务状态约束重复执行,并不承诺 Redis 永不丢消息。

    未确认消息怎样重新被消费

    消费者启动时准备消费组,每个实例使用独立消费者名称。循环先调用 autoClaim(),按游标补领空闲超过 5 分钟的待处理消息,每批最多 10 条;本轮没有补领结果时,再阻塞读取该组尚未投递的新消息。由此,原消费者宕机后,恢复并不依赖它以原名称重新上线。具体顺序见补领与新消息读取。

    补领成功只表示获得消息,还要通过数据库的 PENDING→PROCESSING 条件更新领取业务任务。已完成或无法领取的任务会确认消息并跳过。若旧任务仍停在 PROCESSING,补领消息不会直接使它重新执行,需要下一层数据库恢复将过期状态重置后补投。PEL 状态和业务任务状态是两套记录,不能混为一条状态机。

    业务失败、重试与 ACK 的顺序

    公共消费者的正常路径是:领取任务、执行业务、更新完成状态、ACK。异常路径会检查消息里的 retryCount:小于 3 时重新投递,达到上限后标记失败,再确认旧消息。这里最多是首次执行加 3 次消息重试,业务抛出的异常才会进入这条路径。

    简历分析重投前先条件重置为 PENDING,新消息写入增加后的重试次数;重投入队失败时,把仍待处理的任务标为 FAILED。格式错误消息会直接确认丢弃,ACK 失败则记录日志,后续仍可能再次被补领。见消费确认与异常处理及简历分析重投。

    当前超过重试上限的处理是数据库 FAILED 终态与手动重试,没有独立死信 Stream。若增加死信能力,可以保存原消息、业务标识、异常原因和重试历史,完成持久转存后再确认原消息。另一方面,分析服务会把部分模型调用异常转换为错误报告返回,队列可能仍按正常结果保存并完成;消息成功处理与模型分析结果有效也要分别判断。

    队列记录丢了,数据库怎样兜底

    上传流程先提交简历记录,再发送消息;发送失败会保留文件与记录,并把待处理任务标记失败,供用户重试。这是分步写入,未使用覆盖数据库和 Redis 的原子事务或事务发件箱。

    数据库恢复调度器默认每分钟分别扫描过期的 PENDING 和 PROCESSING:前者默认超过 10 分钟尝试补投;后者默认 15 分钟无进展时条件重置,再补投。恢复有次数上限,避免无限循环,具体见卡住任务恢复调度。阈值需要覆盖排队和实际业务耗时,否则会误判仍在执行的任务。

    消息发送使用近似 MAXLEN=1000 裁剪,未确认消息也可能先被裁掉;补领发现这类 ID 时只记录警告日志,无法恢复已裁剪的消息体。因此数据库任务和已保存的简历正文很关键,但仍需要核对恢复是否成功、失败任务是否有人处理。要进一步提高可靠性,应结合部署环境配置持久化与复制、按积压调整保留策略,并验证“入队前宕机、领取后宕机、结果保存后 ACK 前宕机”三个窗口,而不能只测试正常消费。

  2. 租约失效后为什么还需要 Fencing Token,如何防止旧 Worker 写回?

    展开收起

    核心回答

    InterviewGuide 的简历分析任务通过数据库中的状态和随机 attemptId 识别当前执行者。消费者原子地把任务从 PENDING 改成 PROCESSING,并记录本次尝试;超时恢复或重试会清除旧尝试,新消费者重新领取时生成新的标识。心跳、完成、失败和重试重置均检查当前尝试,分析结果保存也在数据库事务中验证所有权。

    这里采用的是当前尝试的等值校验,能阻止失去执行权的旧消费者写入分析结果或改动新尝试的状态。attemptId 是随机 UUID,并不是面向外部资源的单调递增 Fencing Token;它也无法撤销已经发给模型的分析请求。

    怎样领取与恢复任务

    AnalyzeStreamConsumer.tryMarkProcessing() 生成 UUID,调用 tryMarkAnalyzeProcessing()。只有数据库中仍为 PENDING 的任务才能更新成功,受影响行数为 1 时才继续分析;重复消息或其他消费者不会仅凭消息到达就取得业务执行权。消费者领取、条件领取。

    心跳通过 PROCESSING + analyzeAttemptId 条件更新进展时间,更新失败就抛出执行权失效异常。当前简历分析链在下载解析完成、模型分析返回后报告心跳,并非持续运行独立心跳线程。长调用可能超过无进展阈值,因此阈值需要考虑调用和重试的总耗时。业务阶段与心跳、恢复配置说明。

    恢复调度器扫描无进展任务,以状态和时间条件将过期的 PROCESSING 重置为 PENDING、清除旧 attemptId,再补投消息。旧消费者仍可能继续计算,但它持有的旧标识已经无法匹配当前数据库状态。恢复调度、恢复条件更新。

    为什么保存前再检查仍然有用

    仅靠模型返回后的心跳仍不够:消费者可能刚通过心跳检查,就再次暂停。saveAnalysisIfOwned() 会在事务内通过 findByIdForUpdate() 锁住简历行,重新检查状态和 attemptId,通过后才保存分析结果。这次校验与结果落库受同一个事务保护,恢复调度器不能在二者之间替换任务所有权。锁行查询、事务内验证与结果保存。

    完成、失败以及重试重置也带尝试条件。旧消费者失权后,它的重试重置更新不到当前任务,就不会重新投递旧尝试;旧的完成或失败回调也不会覆盖新尝试。条件状态更新、重试处理。

    分析结果保存与随后标记 COMPLETED 是两个独立事务,不能把这条链描述为“结果、终态和消息确认一次原子提交”。它保护的是相应数据库写回;模型可能被重复调用,端到端也不具备外部副作用只发生一次的保证。消费执行顺序。

    怎样验证旧结果确实被隔离

    可以让尝试 A 停在模型调用中,触发恢复并让 B 取得新 attemptId,再释放 A,检查 A 的心跳与结果写回被拒绝;还要覆盖“A 心跳成功后暂停、B 接管、A 再保存”的时序,以验证最终事务检查。分别核对分析记录、任务状态和外部模型调用次数,才能区分旧结果隔离与重复计算。

  3. 简历评分结果如何结构化输出?

    展开收起

    核心回答

    InterviewGuide 以 Java record 描述简历分析结果,用 BeanOutputConverter 生成格式要求,再通过统一的 StructuredOutputInvoker 调用与解析模型响应。结果包括总分、五项评分、摘要、优点及建议,最终保存为分析记录。

    这一实现解决了结构交接和重试复用,但仍要单独看分值约束与失败语义:当前 DTO 没有表达评分区间,服务外层还可能把失败转换为零分报告,因此结构能保存不等于分析有效。

    DTO 和 Prompt 怎样配合?

    内部 DTO 定义 overallScore、scoreDetail、summary、strengths、suggestions;建议包含类别、优先级、问题和推荐修改。服务把 outputConverter.getFormat() 追加到系统提示词,再将简历正文填入用户模板。当前分析方法接收的是简历正文,没有显式目标岗位参数。DTO 与格式构造。

    系统模板要求项目、技能、内容、结构、表达五项贡献分分别为 40/20/15/15/10,合计 100。对外响应类的部分注释仍是旧的分项上限,不能将注释直接当成当前执行规则;这些标准需要统一维护。实际评分模板、响应字段注释。

    转换失败怎样重试?

    统一调用器默认开启 Schema 校验,通过 call.entity(outputConverter, spec -> spec.validateSchema()) 获取对象。关闭校验的兼容分支才采用文本转换,并尝试修复字符串中的未转义引号;这不是任意 JSON 错误都能修好的通用修复器。两条解析分支。

    外层调用器按配置重试,后续尝试可增加严格 JSON 要求和上一轮错误,默认外层最多尝试两次。Schema 校验和 SDK 自身可能还有内部调用,所以两次不能直接当成全部 HTTP 请求的上限。重试封装、默认配置。

    为什么还需要业务校验和独立失败状态?

    评分 DTO 的分数字段只有 int,没有最小值、最大值或分项合计约束;转换后也没有重新计算总分。生成的对象即使通过现有结构校验,仍可能分值越界或总分不一致。完善时应让分项标准进入可执行约束,再检查总分与评分依据。

    此外,ResumeGradingService 外层捕获异常后返回一份零分错误报告。只要当前消费者仍拥有执行权,它就可以按普通分析结果保存,并随后完成任务。因此不能把任务 COMPLETED 或一条零分分析记录直接解释为评价成功。错误报告回退、结果保存入口。

    执行代次校验能阻止旧消费者写入,却不会判断报告内容有效。进一步完善时,可把分析调用失败、报告校验失败与正常评分分开,避免用户将系统错误理解为自己的简历得了零分;再用缺字段、越界分数、总分冲突和服务不可用样例分别验证。