“码上面试”项目学习02

"码上面试"项目学习02

该部分讲解该项目答题部分的流程和如何维持该流程稳定

首先来看一下流程

flowchart TD Start([候选人提交回答]) --> S1[1. validateRequest: 基础入参校验] S1 --> S2[2. normalizeRequestId: 请求幂等键归一化] S2 --> S3[3. stepIdempotency: 幂等门禁 成功直接回放/处理中拦截] S3 --> S4[4. stepLoadCurrentQuestion: 校验当前题号 拒绝过期题] S4 --> S5[5. stepAcquireQuestionLock: 题级细粒度分布式锁] S5 --> S6[6. stepValidateQuestionAfterLock: Double-Check 锁后二次校验防串题] S6 --> S7[7. stepEvaluateAndScore: 调度评分官 Agent 算账不入账] S7 --> S8[8. stepAdvanceFlowAndAssemble: 触发 LiteFlow 追问裁决, 推进下一题] S8 --> Finish([返回结果与下一题])

第一幕:安检门与幂等防重(步骤 1 ~ 3)

  1. 步骤 1(基础校验):检查张三的答案是不是空的,字数有没有超限。
  2. 步骤 2(生成稳定凭据) :根据 sessionId + 题号 + 答案内容 计算一个唯一的 requestId。
  3. 步骤 3(幂等门禁):拿着这个requestId去 Redis 查:
    • 如果发现张三刚才已经提交成功过了,直接把上次的结果原封不动返回(回放);
    • 如果发现后台正在计算中,直接提示"正在处理中,请稍候"。
    • 目的:彻底防住张三连点提交按钮。

第二幕:拿钥匙与 Double-Check 防串题(步骤 4 ~ 6)

  1. 步骤 4(核对题号):从缓存读取当前状态机游标。确认张三当前确实应该答第 1 题,拒绝"拿第 1 题的答案来答第 2 题"的过期请求。
  2. 步骤 5(获取题级锁):
    • 系统通过 Redisson 拿一把分布式锁:lock:session_1001:1。
    • 为什么只锁第 1 题? 因为张三答第 1 题,不影响别人答题,也不影响全局状态,锁的范围越小,系统并发越高。
  3. 步骤 6( Double-Check 二度校验):
    • 思考:假设张三网速卡,连续发了请求 A 和请求 B。请求 A 先拿到了锁,把第 1 题答完了,题目已经被推进到了第 2 题;
    • 此时请求 B 排队结束,终于拿到了锁。如果请求 B 不做二次检查,它就会把第 1 题的答案错误地写入到第 2 题上(这就是串题 Bug)!
    • 所以,拿到锁后必须再查一次当前题号,发现题号变了,立刻拦截!

第三幕:调度答案评分官 Agent(步骤 7)

  1. 系统呼叫"答案评分官 Agent":

    • 系统把原题(MySQL索引 )和张三的回答(主要是B树吧)组装成提示词,发给大模型。

    • 评分官 Agent(大模型)分析后返回结构化 JSON:

      json 复制代码
      {
      
        "score": 50,
      
        "feedback": "回答过于简陋,未说明 B+ 树特性,漏掉了叶子节点双向链表和范围查询优势。",
      
        "missing_points": ["B+树区别", "范围查询", "双向链表"],
      
        "follow_up_needed": true,
      
        "follow_up_question": "你能具体说说 B+ 树为什么比 B 树更适合做磁盘索引吗?"
      
      }
  2. 核心心智:算账不入账:

    • 系统解析出:得分 50 分,有遗漏考点。
    • 注意 :此时系统只是把 50 分存到了本次请求的上下文 ctx 变量里。绝对没有修改 MySQL,也没有修改 Redis 里的总分!
    • 为什么?因为万一下一步网络断了或者保存失败,张三的会话状态依然是干干净净的第 1 题,不会产生脏数据。

第四幕:裁判团 LiteFlow 裁决与追问官唤醒(步骤 8)

虽然刚才大模型说:"我想追问(follow_up_needed: true)",但系统绝不会盲目听从大模型! 因为大模型不可控,如果它犯轴连环追问张三 20 次,面试就永远无法结束了。

此时,后端的 LiteFlow 规则引擎(裁判团) 登场:

xml 复制代码
THEN(loadFollowUpContext, completedStateGuard, followUpLimitGuard,

     aiSuggestionJudge, lowScoreJudge, missingPointsJudge, followUpDecisionFinalize);
LiteFlow 裁判团内部的流水线审理过程:
  1. loadFollowUpContext(加载案卷):裁判团看到数据:张三得了 50 分,这道题之前追问过 0 次,最大允许追问 2 次。
  2. completedStateGuard(面试结束门禁):检查整场面试结束了吗?没结束,继续。
  3. followUpLimitGuard(追问上限门禁) :检查这道题是不是已经追问满 2 次了?当前是第 0 次,没超限,允许追问。
  4. lowScoreJudge(低分规则判定) :
    • 打开看代码:
    • if (score < 60):张三得了 50 分,低于及格线 60 分!裁判直接盖章:markNeedFollowUp("LOW_SCORE")!
  5. followUpDecisionFinalize(最终宣判) :
    • 裁判团正式下发决议:必须追问!
接下来系统的行动:
  • 唤醒"追问官 Agent":

    拿着缺失考点,正式生成了一道追问题,编号为1-1

    (第 1 题的第 1 次追问):

    • "你能具体说说 B+ 树为什么比 B 树更适合做磁盘索引吗?"
  • 推进状态机:

    • 系统把题目游标更新为 1-1,等待张三下一次作答;
    • 正式将张三的 50 分记录入库。
  • 返回前端:

    • 前端界面跳出弹窗:"您本次回答得分 50 分。面试官向您发起了进一步追问:追问题目内容"。

对比理解:如果张三回答得很完美(得了 90 分)会怎样?

如果张三答得头头是道,大模型给了 90 分:

  1. LiteFlow 裁判团审查:
    • lowScoreJudge:90 分 >= 60 分,不触发低分追问;
    • missingPointsJudge:没有明显遗漏点,不触发漏点追问;
  2. **裁判团宣判:**不追问,放行!
  3. 系统行动:
    • 调用 interviewFlowStateMachine.advanceMainQuestion(),直接将题目推进到第 2 题(主问题)!
    • 此时总分正式累加入账:0 + 90 = 90 分;
    • 前端直接跳出第 2 道全新试题(如:"请简述 Spring 事务传播机制")。

下面详细讲解一下这个二次锁,这个非常惊艳

首先我们来理解一下这个二次锁是干什么的,想象一个场景

假设候选人张三在答第 1 题(MySQL 索引)。 因为张三电脑卡顿,他连续按了两次提交,或者浏览器网络重试,向后端几乎同时发送了两个请求:

  • 请求 A:答第 1 题,内容:"主要是 B 树"
  • 请求 B:答第 1 题,内容:"是 B+ 树"

我们来看看服务器内部在时间轴(Timeline)上发生了什么:

sequenceDiagram autonumber participant A as 请求 A (先到) participant B as 请求 B (后到) participant Lock as Redis 分布式锁 (第1题) participant DB as 会话状态机 (题号游标) Note over DB: 当前系统状态:第 1 题 A->>DB: 步骤4: 检查题号 (发现是第1题,匹配通过!) A->>Lock: 步骤5: 抢第1题的分布式锁 (抢到了!) Note over A: 请求 A 开始调大模型打分 (耗时 3 秒...) Note over B: 此时请求 B 也进来了 B->>DB: 步骤4: 检查题号 (A还在算,系统依然是第1题,B也通过了!) B->>Lock: 步骤5: 去抢第1题的锁 Note over B: 发现被 A 占用了!请求 B 只能原地挂起排队... Note over A: 3秒后,A 算完并推进题号 A->>DB: 步骤8: 状态机推进!当前题号变更为【第 2 题】 A->>Lock: 释放第1题的锁,请求 A 圆满结束! Note over B: 关键时刻!锁被释放,排队的请求 B 瞬间被唤醒拿到锁! Note over B: 如果没有二次校验: B就会拿着第1题的答案,去覆盖第2题! B->>DB: 步骤6 (Double-Check): 查当前题号是多少? DB-->>B: 返回:现在已经是【第 2 题】了! Note over B: B 发现游标漂移了!立刻报错拦截,拒绝执行!
  • 假设系统【没有步骤 6 的二次校验】:

    • 请求 B 拿到了锁,它心里想:"我刚才在 00:01 秒的步骤 4 已经检查过题号是第 1 题了,没毛病,继续往下跑!"
    • 请求 B 拿着自己第 1 题的答案,去调评分、去推状态机......
    • 灾难发生:此时系统明明已经在第 2 题了,请求 B 却把第 2 题又当成第 1 题给推了!
    • 候选人看到的灵异现象 :张三界面上的第 1 题刚提交完,屏幕突然闪了一下,他连第 2 题长什么样都还没看清,系统就已经自动把第 2 题给交卷了,直接跳到了第 3 题! 这就是高并发下最典型的**"游标漂移串题事故"**!
  • 而因为系统【加了步骤 6 的二次校验(Double-Check)】:

    • 请求 B 在 00:03 拿到锁后,执行代码:

      java 复制代码
      // 拿到了锁,立刻重新查一下最新的系统题号
      
      InterviewFlowState lockedFlow = interviewFlowStateMachine.current(ctx.sessionId);
      
      String lockedQuestionNumber = lockedFlow.currentQuestionNumber(); // 此时查出来是 "2"
      
      
      
      // 拿我请求参数里的题号 "1",跟最新题号 "2" 比对
      
      if (!isRequestedQuestionCurrent(ctx.currentQuestionNumber, lockedQuestionNumber)) {
      
          // 发现不一致!记录监控指标,断然拒绝!
      
          ctx.response.fail("stale question number, please refresh current question");
      
          return false;
      
      }
    • 请求 B 发现自己手里拿的是"过期的过期票(Stale Question)",直接快速报错拦截,绝不往下污染第 2 题的状态!

那为什么当b发现这个锁被拿时不直接结束呢,还要等这,不是有人回答第二题了吗?
真相一:B 在到达的那一秒,根本不知道 A 会成功还是会失败!

我们站在 请求 B 的视角 想一想: 当请求 B 到达时,请求 A 正在调用大模型打分(耗时 3 秒)。 此时系统数据库里的题号依然是第 1 题,还没人回答第 2 题!

那么请问:请求 A 接下来一定能成功吗? 不一定!

  • 如果大模型超时报错了呢?
  • 如果请求 A 的网络突然断了呢?
  • 如果请求 A 发生异常回滚了呢?

如果请求 A 失败了,那么这次作答其实是无效的,系统依然停留在第 1 题! 如果此时 B 只是因为看到 A 占着锁就"草率地直接放弃",那张三的这次答题就彻底卡死了。


真相二:锁的两种模式------"秒杀直接挂" vs "短暂等一等"

打开代码 InterviewQuestionLockService.java第 31 行:

ini 复制代码
java





acquired = lock.tryLock(resolveWaitMillis(), resolveExpireMillis(), TimeUnit.MILLISECONDS);

在企业级真实部署中,加锁策略有两种配置:

模式 1:零等待(Fail-Fast,直接结束)
  • 如果系统配置 lockWaitMillis = 0: 正如你所说的,请求 B 发现锁被 A 占了,立刻直接返回失败 ,提示:"current question is processing, please retry later"。 在这种情况下,B 确实直接结束了!
模式 2:短暂容错等待(Wait & Retry,比如等 500 毫秒)
  • 为什么有时候要让 B "等一下"?
    • 设想:A 此时其实已经算完了,正在执行最后的释放锁(只差 10 毫秒就结束了);
    • 如果 B 连 10 毫秒都不等,直接弹个大红框报错给候选人,候选人会觉得系统非常脆弱;
    • 所以有些生产配置会允许 B 短暂等个几百毫秒 (waitMillis > 0)。如果 A 刚好释放了,B 就能无缝接上,候选人完全无感知。

真相三:为什么必须有步骤 6?

现在关键来了! 如果生产环境开启了"短暂等一等(Wait & Retry)"模式:

  1. 请求 B 等了 200 毫秒;
  2. 此时请求 A 成功完成了第 1 题,把系统推进到了第 2 题,然后释放了锁;
  3. 请求 B 在第 201 毫秒被唤醒,成功抢到了这把刚刚释放的锁!

问:此时请求 B 已经拿到锁了,它该不该执行? 绝对不该! 因为此时虽然 B 拿到了锁,但 A 已经把事情做完了,系统已经跳到第 2 题了!

所以,代码才必须在拿到锁之后,加上 步骤 6(二次检查 Double-Check):

java 复制代码
// B 拿到锁之后,第一眼看系统:发现题号变成 2 了!

if (!isRequestedQuestionCurrent(ctx.currentQuestionNumber, lockedQuestionNumber)) {

    // B 发现题号过期了,立刻刹车!

    ctx.response.fail("stale question number, please refresh current question");

    return false;

}
相关推荐
大白801 小时前
多模态大模型能干什么?图文音视频统一理解,正在重画 AI 的边界
后端
mldong1 小时前
同一个审批流引擎,我写了两次:一次 11226 行,一次 7036 行
后端·架构
百万蹄蹄向前冲1 小时前
一张平面图把学校做成2D游戏
前端·人工智能·后端
沙蒿同学1 小时前
一个人做完一套企业级系统,赚到了第一个 1000 块
vue.js·后端·go
机器之心1 小时前
突发:Claude自主发现未知生物系统,或能编辑基因
前端·人工智能·后端
用户8356290780511 小时前
使用 Python 在 Excel 中添加和管理图片
后端·python
码事漫谈1 小时前
AI巨头集体喊“慢”,但谁都不敢先踩刹车
后端
double_face1 小时前
AgentScope 2.0 源码解析系列(Java 版)|第 3 篇:State 模块,Agent 靠什么记住这轮对话
后端·面试
用户6802659051191 小时前
企业电脑统一管理怎么做?2026企业终端统一管理方法与工具推荐
javascript·后端·面试