"码上面试"项目学习02
该部分讲解该项目答题部分的流程和如何维持该流程稳定
首先来看一下流程
第一幕:安检门与幂等防重(步骤 1 ~ 3)
- 步骤 1(基础校验):检查张三的答案是不是空的,字数有没有超限。
- 步骤 2(生成稳定凭据) :根据
sessionId + 题号 + 答案内容计算一个唯一的requestId。 - 步骤 3(幂等门禁):拿着这个requestId去 Redis 查:
- 如果发现张三刚才已经提交成功过了,直接把上次的结果原封不动返回(回放);
- 如果发现后台正在计算中,直接提示"正在处理中,请稍候"。
- 目的:彻底防住张三连点提交按钮。
第二幕:拿钥匙与 Double-Check 防串题(步骤 4 ~ 6)
- 步骤 4(核对题号):从缓存读取当前状态机游标。确认张三当前确实应该答第 1 题,拒绝"拿第 1 题的答案来答第 2 题"的过期请求。
- 步骤 5(获取题级锁):
- 系统通过 Redisson 拿一把分布式锁:
lock:session_1001:1。 - 为什么只锁第 1 题? 因为张三答第 1 题,不影响别人答题,也不影响全局状态,锁的范围越小,系统并发越高。
- 系统通过 Redisson 拿一把分布式锁:
- 步骤 6( Double-Check 二度校验):
- 思考:假设张三网速卡,连续发了请求 A 和请求 B。请求 A 先拿到了锁,把第 1 题答完了,题目已经被推进到了第 2 题;
- 此时请求 B 排队结束,终于拿到了锁。如果请求 B 不做二次检查,它就会把第 1 题的答案错误地写入到第 2 题上(这就是串题 Bug)!
- 所以,拿到锁后必须再查一次当前题号,发现题号变了,立刻拦截!
第三幕:调度答案评分官 Agent(步骤 7)
-
系统呼叫"答案评分官 Agent":
-
系统把原题(MySQL索引 )和张三的回答(主要是B树吧)组装成提示词,发给大模型。
-
评分官 Agent(大模型)分析后返回结构化 JSON:
json{ "score": 50, "feedback": "回答过于简陋,未说明 B+ 树特性,漏掉了叶子节点双向链表和范围查询优势。", "missing_points": ["B+树区别", "范围查询", "双向链表"], "follow_up_needed": true, "follow_up_question": "你能具体说说 B+ 树为什么比 B 树更适合做磁盘索引吗?" }
-
-
核心心智:算账不入账:
- 系统解析出:得分 50 分,有遗漏考点。
- 注意 :此时系统只是把 50 分存到了本次请求的上下文
ctx变量里。绝对没有修改 MySQL,也没有修改 Redis 里的总分! - 为什么?因为万一下一步网络断了或者保存失败,张三的会话状态依然是干干净净的第 1 题,不会产生脏数据。
第四幕:裁判团 LiteFlow 裁决与追问官唤醒(步骤 8)
虽然刚才大模型说:"我想追问(follow_up_needed: true)",但系统绝不会盲目听从大模型! 因为大模型不可控,如果它犯轴连环追问张三 20 次,面试就永远无法结束了。
此时,后端的 LiteFlow 规则引擎(裁判团) 登场:
xml
THEN(loadFollowUpContext, completedStateGuard, followUpLimitGuard,
aiSuggestionJudge, lowScoreJudge, missingPointsJudge, followUpDecisionFinalize);
LiteFlow 裁判团内部的流水线审理过程:
loadFollowUpContext(加载案卷):裁判团看到数据:张三得了 50 分,这道题之前追问过 0 次,最大允许追问 2 次。completedStateGuard(面试结束门禁):检查整场面试结束了吗?没结束,继续。followUpLimitGuard(追问上限门禁) :检查这道题是不是已经追问满 2 次了?当前是第 0 次,没超限,允许追问。lowScoreJudge(低分规则判定) :- 打开看代码:
if (score < 60):张三得了 50 分,低于及格线 60 分!裁判直接盖章:markNeedFollowUp("LOW_SCORE")!
followUpDecisionFinalize(最终宣判) :- 裁判团正式下发决议:必须追问!
接下来系统的行动:
-
唤醒"追问官 Agent":
拿着缺失考点,正式生成了一道追问题,编号为
1-1(第 1 题的第 1 次追问):
- "你能具体说说 B+ 树为什么比 B 树更适合做磁盘索引吗?"
-
推进状态机:
- 系统把题目游标更新为
1-1,等待张三下一次作答; - 正式将张三的 50 分记录入库。
- 系统把题目游标更新为
-
返回前端:
- 前端界面跳出弹窗:"您本次回答得分 50 分。面试官向您发起了进一步追问:追问题目内容"。
对比理解:如果张三回答得很完美(得了 90 分)会怎样?
如果张三答得头头是道,大模型给了 90 分:
- LiteFlow 裁判团审查:
lowScoreJudge:90 分 >= 60 分,不触发低分追问;missingPointsJudge:没有明显遗漏点,不触发漏点追问;
- **裁判团宣判:**不追问,放行!
- 系统行动:
- 调用
interviewFlowStateMachine.advanceMainQuestion(),直接将题目推进到第 2 题(主问题)! - 此时总分正式累加入账:0 + 90 = 90 分;
- 前端直接跳出第 2 道全新试题(如:"请简述 Spring 事务传播机制")。
- 调用
下面详细讲解一下这个二次锁,这个非常惊艳
首先我们来理解一下这个二次锁是干什么的,想象一个场景
假设候选人张三在答第 1 题(MySQL 索引)。 因为张三电脑卡顿,他连续按了两次提交,或者浏览器网络重试,向后端几乎同时发送了两个请求:
- 请求 A:答第 1 题,内容:"主要是 B 树"
- 请求 B:答第 1 题,内容:"是 B+ 树"
我们来看看服务器内部在时间轴(Timeline)上发生了什么:
-
假设系统【没有步骤 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)"模式:
- 请求 B 等了 200 毫秒;
- 此时请求 A 成功完成了第 1 题,把系统推进到了第 2 题,然后释放了锁;
- 请求 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;
}